diff --git a/README.md b/README.md
index e916b51..aac7209 100644
--- a/README.md
+++ b/README.md
@@ -112,8 +112,22 @@ make install-app # Splice.app in /Applications
- **Place:** drag a picture on the stage, or its corners to resize. It snaps
to the centre lines. There's a nine-point grid, plus size, opacity and
position sliders.
+ - **Keyframes:** the ◆ beside size, opacity and position keys that value at
+ the playhead; set another somewhere else and it glides between them
+ (linear, smooth, ease in, ease out, or hold). Dragging a keyframed picture
+ on the stage keys it where the playhead is. ⌥K keys place and size at once.
+ - **Transition:** the way into a clip from the one before it: dissolve, dip
+ to black or white, wipe or push four ways, or zoom, centred on the cut,
+ with a length. A tab on the timeline marks each one. ⇧⌘T adds a dissolve
+ at the nearest cut.
- **Animate:** in and out with fade, slide (four ways), zoom or pop, with a
length you choose. A slow zoom can run across the whole clip.
+ - **Colour:** a look (warm, cool, vivid, muted, faded, film, teal & orange,
+ black & white, noir) with exposure, contrast, saturation, warmth and tint
+ over it. *Use on every clip* grades the whole movie the same.
+ - **Speed:** 0.25× to 4× (0.1× to 16× from the assistant), or backwards.
+ A faster clip gets shorter and what follows on its track moves up; sound
+ keeps its pitch. *Freeze frame* (⌥F) holds the frame at the playhead.
- **Green screen:** key out green or blue, with strength, softness and fringe
clean-up.
- **Crop:** cut any edge away. The picture keeps its scale, and what
@@ -124,8 +138,8 @@ make install-app # Splice.app in /Applications
The viewer and export share one renderer, so what you see is what you get.
Titles are drawn once in Rust (cosmic-text) and that same picture is overlaid
on export. Keys and masks are ffmpeg filters that the viewer's decoder runs
-too. Animation is one expression, evaluated for the viewer and printed into
-ffmpeg for export.
+too, and so is colour. Animation, keyframes and transitions are one set of
+expressions, evaluated for the viewer and printed into ffmpeg for export.
## Scores and scenes
@@ -298,7 +312,10 @@ with `make install-skill`) teaches Claude Code to use it.
| `splice ctl add / move / trim / split / delete / duplicate / volume` | timeline edits, each one undo step |
| `splice ctl track`, `canvas`, `undo`, `redo` | layers, canvas shape, history |
| `splice ctl text`, `color`, `style`, `fonts` | titles in system fonts, colour cards and tints |
-| `splice ctl look` | position, size, opacity, animation, green screen, mask |
+| `splice ctl look` | position, size, opacity, animation, green screen, mask, crop, colour |
+| `splice ctl transition` | dissolves, dips, wipes, pushes and zooms at cuts |
+| `splice ctl speed`, `freeze` | faster, slower, backwards; hold a frame |
+| `splice ctl keyframe` | animate position, size and opacity between moments |
| `splice ctl score new / read / write / instruments` | compose music: a score as JSON |
| `splice ctl scene new / read / write / help` | animations and diagrams, described as JSON |
| `splice ctl scene sheet / frame / patch / add / remove / check` | see a scene's motion as a contact sheet; edit layers by name; validate first |
diff --git a/crates/splice-app/src/actions.rs b/crates/splice-app/src/actions.rs
index f509331..d02b295 100644
--- a/crates/splice-app/src/actions.rs
+++ b/crates/splice-app/src/actions.rs
@@ -55,6 +55,10 @@ actions!(
NewScene,
MakeSymbol,
BreakApart,
+ AddTransition,
+ FreezeFrame,
+ ReverseClip,
+ AddKeyframe,
ShowSymbols,
ShowMedia,
ShowTextStyles,
@@ -106,6 +110,9 @@ pub fn bind(cx: &mut App) {
KeyBinding::new("cmd-shift-r", RecordScreen, G),
KeyBinding::new("cmd-alt-k", MakeSymbol, G),
KeyBinding::new("cmd-alt-b", BreakApart, G),
+ KeyBinding::new("cmd-shift-t", AddTransition, G),
+ KeyBinding::new("alt-f", FreezeFrame, W),
+ KeyBinding::new("alt-k", AddKeyframe, W),
KeyBinding::new("b", AddBeat, W),
KeyBinding::new("c", AddCue, W),
KeyBinding::new("cmd-v", PasteReference, W),
@@ -191,6 +198,11 @@ pub fn menus() -> Vec
{
MenuItem::action("Ripple Delete", RippleDelete),
MenuItem::action("Duplicate", Duplicate),
MenuItem::separator(),
+ MenuItem::action("Add Transition", AddTransition),
+ MenuItem::action("Freeze Frame", FreezeFrame),
+ MenuItem::action("Play Backwards", ReverseClip),
+ MenuItem::action("Add Keyframe", AddKeyframe),
+ MenuItem::separator(),
MenuItem::action("Make Symbol…", MakeSymbol),
MenuItem::action("Break Symbol Apart", BreakApart),
MenuItem::separator(),
@@ -289,6 +301,18 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box)> {
Box::new(MakeSymbol),
),
("Break a symbol apart into its clips", "⌥⌘B", Box::new(BreakApart)),
+ (
+ "Add a transition (dissolve) at the cut nearest the playhead",
+ "⇧⌘T",
+ Box::new(AddTransition),
+ ),
+ (
+ "Freeze frame: hold the frame at the playhead",
+ "⌥F",
+ Box::new(FreezeFrame),
+ ),
+ ("Play the selection backwards", "", Box::new(ReverseClip)),
+ ("Keyframe place and size at the playhead", "⌥K", Box::new(AddKeyframe)),
("Show symbols", "", Box::new(ShowSymbols)),
("Story view / edit view", "⌘3", Box::new(ToggleStory)),
("Add a story beat", "B", Box::new(AddBeat)),
diff --git a/crates/splice-app/src/audio.rs b/crates/splice-app/src/audio.rs
index cdcc575..36a91ff 100644
--- a/crates/splice-app/src/audio.rs
+++ b/crates/splice-app/src/audio.rs
@@ -13,8 +13,27 @@ pub struct Voice {
pub length: u64,
/// Frame in the PCM the clip begins at.
pub source_in: u64,
+ /// PCM frames per frame of playback: 1 normally; a clip whose retimed
+ /// sound is not ready yet plays its own faster or slower (or, negative,
+ /// backwards), pitch and all.
+ pub step: f64,
pub volume: f32,
pub pcm: Arc,
+ /// Rising and falling across transitions: timeline frames, start and end.
+ pub fade_in: Option<(u64, u64)>,
+ pub fade_out: Option<(u64, u64)>,
+}
+
+impl Voice {
+ /// Quarter-sine fades, as export makes them (`afade` with `qsin`).
+ fn gain(&self, t: u64) -> f32 {
+ let ramp = |(a, z): (u64, u64), rising: bool| {
+ let p = (t.saturating_sub(a) as f64 / (z.saturating_sub(a)).max(1) as f64).clamp(0.0, 1.0);
+ let p = if rising { p } else { 1.0 - p };
+ (p * std::f64::consts::FRAC_PI_2).sin() as f32
+ };
+ self.fade_in.map_or(1.0, |f| ramp(f, true)) * self.fade_out.map_or(1.0, |f| ramp(f, false))
+ }
}
#[derive(Default)]
@@ -162,10 +181,16 @@ fn fill(mix: &Mutex, out: &mut [S], channels: usize, con
if t < voice.start || t >= voice.start + voice.length {
continue;
}
- let index = (voice.source_in + (t - voice.start)) as usize * 2;
+ let along = (t - voice.start) as f64 * voice.step;
+ let frame = voice.source_in as f64 + along;
+ if frame < 0.0 {
+ continue;
+ }
+ let index = frame as usize * 2;
if index + 1 < voice.pcm.samples.len() {
- left += voice.pcm.samples[index] as f32 / 32768.0 * voice.volume;
- right += voice.pcm.samples[index + 1] as f32 / 32768.0 * voice.volume;
+ let gain = voice.volume * voice.gain(t);
+ left += voice.pcm.samples[index] as f32 / 32768.0 * gain;
+ right += voice.pcm.samples[index + 1] as f32 / 32768.0 * gain;
}
}
let (left, right) = mix.limiter.apply(left, right);
diff --git a/crates/splice-app/src/cli.rs b/crates/splice-app/src/cli.rs
index 74e82d1..6e62abf 100644
--- a/crates/splice-app/src/cli.rs
+++ b/crates/splice-app/src/cli.rs
@@ -150,6 +150,11 @@ fn export_project(path: &Path, out: &Path, quality: Quality) -> anyhow::Result<(
canvas: (project.settings.width, project.settings.height),
};
let plan = export::plan(&project, &resolver, out, quality).map_err(|e| anyhow::anyhow!(e))?;
+ let prepared = ffmpeg::prepare(&plan, || true);
+ if prepared.is_err() {
+ ffmpeg::tidy(&plan);
+ }
+ prepared?;
let mut child = ffmpeg::export_command()?
.args(&plan.args)
.stdout(Stdio::piped())
@@ -163,7 +168,9 @@ fn export_project(path: &Path, out: &Path, quality: Quality) -> anyhow::Result<(
let _ = std::io::stdout().flush();
}
}
- let status = child.wait()?;
+ let status = child.wait();
+ ffmpeg::tidy(&plan);
+ let status = status?;
println!();
if !status.success() {
anyhow::bail!("ffmpeg failed");
diff --git a/crates/splice-app/src/ctl.rs b/crates/splice-app/src/ctl.rs
index 9108144..10482aa 100644
--- a/crates/splice-app/src/ctl.rs
+++ b/crates/splice-app/src/ctl.rs
@@ -56,8 +56,30 @@ Titles, colours and looks
[--feather F] [--invert/--no-invert] (mask numbers are fractions of the clip's frame)
--crop-left F --crop-right F --crop-top F --crop-bottom F | --crop none
(cut edges away, fractions of the picture; it keeps its size and what remains stays put)
+ --grade none|warm|cool|vivid|muted|faded|film|teal-orange|mono|noir (a ready-made look)
+ --exposure F (stops, −1 to 1) --contrast F --saturation F --warmth F --tint F (each −1 to 1)
+ (colour, for video and pictures; `look --all` gives every video and picture the same)
C is #rrggbb, #rrggbbaa or a name: white, black, paper, ink, vermilion, gold, green, blue, clear.
+Transitions, speed and keyframes
+ transition --clip C --kind KIND [--for S] | --none
+ the way into clip C from the clip before it (centred on the cut), or,
+ with nothing just before, in over what is beneath. KIND: dissolve,
+ dip-black, dip-white, wipe-left/right/up/down, push-left/right/up/down,
+ zoom. Default length 0.6s
+ transition --all --kind KIND [--for S] [--track N]
+ the same at every cut between two clips (on track N)
+ speed --clip C [--rate R] [--reverse|--forwards]
+ play at R (2 is twice as fast, 0.5 half; 0.1 to 16): the clip gets
+ shorter or longer over the same source, and what follows on its track
+ moves to match. Sound keeps its pitch
+ freeze --clip C [--at T] [--for S] hold the frame at T (default: the playhead) for S (default 2)
+ keyframe --clip C --at T [--x F] [--y F] [--scale F] [--opacity F] [--ease EASE]
+ set values at timeline time T; between keyframes they glide. EASE
+ (from this keyframe to the next): linear, in, out, in-out (default), hold
+ keyframe --clip C --at T --drop [--prop x|y|scale|opacity] take keyframes at T away
+ keyframe --clip C --clear [--prop NAME] take them all away; values stay as they are at the playhead
+
Music (a score: instruments and notes, played by General MIDI sounds)
score new [--file score.json] [--name N] [--at T] [--track N] [--wait]
add a score to the media (the starter if no file); with --at, place it
@@ -425,12 +447,13 @@ fn build(args: &[String]) -> Result {
let mut next = |name: &str| rest.next().ok_or(format!("{name} needs a value"));
match arg.as_str() {
"--font" | "--color" | "--box" | "--align" | "--enter" | "--exit" | "--drift" | "--key" | "--mask"
- | "--query" | "--crop" => {
+ | "--query" | "--crop" | "--grade" | "--ease" | "--prop" => {
request.insert(arg[2..].into(), json!(value(next(arg)?)?));
}
"--size" | "--x" | "--y" | "--scale" | "--opacity" | "--similarity" | "--blend" | "--feather"
| "--mask-x" | "--mask-y" | "--mask-width" | "--mask-height" | "--enter-for" | "--exit-for"
- | "--crop-left" | "--crop-top" | "--crop-right" | "--crop-bottom" => {
+ | "--crop-left" | "--crop-top" | "--crop-right" | "--crop-bottom" | "--exposure" | "--contrast"
+ | "--saturation" | "--warmth" | "--tint" | "--rate" => {
let raw = next(arg)?;
let v = match raw.parse::() {
Ok(v) => json!(v),
@@ -517,9 +540,12 @@ fn build(args: &[String]) -> Result {
let v: f64 = next(arg)?.parse().map_err(|_| format!("{arg} expects points"))?;
request.insert(arg[2..].into(), json!(v));
}
- "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" => {
+ "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" | "--drop" => {
request.insert(arg[2..].into(), json!(true));
}
+ "--reverse" | "--forwards" => {
+ request.insert("reverse".into(), json!(arg == "--reverse"));
+ }
"--draft" | "--high" => {
request.insert("quality".into(), json!(&arg[2..]));
}
@@ -539,7 +565,7 @@ fn build(args: &[String]) -> Result {
let cmd = match command.as_str() {
"status" | "read" | "versions" | "add" | "move" | "trim" | "split" | "delete" | "duplicate" | "volume"
| "track" | "canvas" | "undo" | "redo" | "play" | "pause" | "select" | "save" | "say" | "wait" | "text"
- | "color" | "style" | "look" | "fonts" => command.as_str(),
+ | "color" | "style" | "look" | "fonts" | "transition" | "speed" | "freeze" | "keyframe" => command.as_str(),
"restore" => {
if let Some(id) = words.first().and_then(|w| w.parse::().ok()) {
request.insert("version".into(), json!(id));
diff --git a/crates/splice-app/src/editor.rs b/crates/splice-app/src/editor.rs
index a9d3964..09ccf60 100644
--- a/crates/splice-app/src/editor.rs
+++ b/crates/splice-app/src/editor.rs
@@ -30,6 +30,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
mod agent;
+mod effects;
mod input;
mod inspector;
mod made;
@@ -168,6 +169,7 @@ struct ExportJob {
progress: f32,
done: Option>,
child: Option,
+ cancelled: bool,
}
struct Status {
@@ -176,6 +178,18 @@ struct Status {
at: Instant,
}
+/// Names the retimed sound of a clip: what it plays, how fast, which way.
+fn retimed_key(clip: &splice_core::model::Clip, media: &Media) -> String {
+ format!(
+ "{}:{}:{}:{}:{}",
+ media.hash,
+ clip.source_in,
+ clip.span(),
+ clip.speed,
+ clip.reverse
+ )
+}
+
pub struct Editor {
focus: FocusHandle,
palette_focus: FocusHandle,
@@ -183,6 +197,9 @@ pub struct Editor {
library: Library,
player: Player,
audio: AudioOut,
+ /// The sound of clips played at another speed or backwards, by
+ /// `retimed_key`; None while it is decoded.
+ retimed_sound: Arc>>>>,
selection: Vec,
bin_selection: Option,
view: View,
@@ -350,6 +367,7 @@ impl Editor {
library: Library::default(),
player: Player::new(),
audio: AudioOut::open(),
+ retimed_sound: Default::default(),
selection: Vec::new(),
bin_selection: None,
view: View::default(),
@@ -734,6 +752,7 @@ impl Editor {
fn after_edit(&mut self) {
let project = &self.doc.project;
self.selection.retain(|id| project.slot(*id).is_some());
+ self.prepare_retimed_sound();
if self.player.is_playing() {
self.audio.stop();
self.player.seek(self.player.position);
@@ -764,26 +783,112 @@ impl Editor {
}
fn start_sound(&mut self) {
+ self.prepare_retimed_sound();
let audio = &self.audio;
- let voices = self
- .doc
- .project
+ let project = &self.doc.project;
+ let retimed = self.retimed_sound.lock().unwrap();
+ let voices = project
.audible_clips()
.into_iter()
+ .filter(|(clip, _)| !clip.frozen)
.filter_map(|(clip, media)| {
+ // When it plays and how it fades, as export makes it.
+ let sound = project.sound(clip);
+ let fades = |f: Option<(Time, Time)>| f.map(|(a, z)| (audio.frames(a), audio.frames(z)));
+ let (fade_in, fade_out) = (fades(sound.fade_in), fades(sound.fade_out));
+ let volume = clip.volume;
+ // A clip played faster, slower or backwards sounds as export
+ // makes it, once that is decoded; until then, its own sound
+ // retimed on the fly.
+ if clip.is_retimed() {
+ let (start, length) = (audio.frames(clip.start), audio.frames(clip.duration));
+ if let Some(Some(pcm)) = retimed.get(&retimed_key(clip, media)) {
+ return Some(Voice {
+ start,
+ length,
+ source_in: 0,
+ step: 1.0,
+ volume,
+ pcm: pcm.clone(),
+ fade_in,
+ fade_out,
+ });
+ }
+ let pcm = self.library.get(&media.hash)?.pcm.clone()?;
+ let (source_in, step) = if clip.reverse {
+ (audio.frames(clip.source_out()).saturating_sub(1), -clip.speed)
+ } else {
+ (audio.frames(clip.source_in), clip.speed)
+ };
+ return Some(Voice {
+ start,
+ length,
+ source_in,
+ step,
+ volume,
+ pcm,
+ fade_in,
+ fade_out,
+ });
+ }
let pcm = self.library.get(&media.hash)?.pcm.clone()?;
Some(Voice {
- start: audio.frames(clip.start),
- length: audio.frames(clip.duration),
- source_in: audio.frames(clip.source_in),
- volume: clip.volume,
+ start: audio.frames(sound.from),
+ length: audio.frames(sound.to - sound.from),
+ source_in: audio.frames(sound.source),
+ step: 1.0,
+ volume,
pcm,
+ fade_in,
+ fade_out,
})
})
.collect();
+ drop(retimed);
self.audio.play(self.player.position, voices);
}
+ /// Decodes, in the background, the sound of every clip played at
+ /// another speed or backwards, as export will make it.
+ fn prepare_retimed_sound(&mut self) {
+ let rate = self.audio.rate;
+ let mut wanted = Vec::new();
+ for (clip, media) in self.doc.project.audible_clips() {
+ if !clip.is_retimed() || clip.frozen {
+ continue;
+ }
+ let key = retimed_key(clip, media);
+ let Some(path) = self.library.path(&media.hash) else {
+ continue;
+ };
+ wanted.push((
+ key,
+ path.to_path_buf(),
+ clip.source_in,
+ clip.span(),
+ clip.speed,
+ clip.reverse,
+ ));
+ }
+ let mut cache = self.retimed_sound.lock().unwrap();
+ let keep: std::collections::HashSet<&String> = wanted.iter().map(|w| &w.0).collect();
+ cache.retain(|k, _| keep.contains(k));
+ for (key, path, from, span, speed, reverse) in wanted {
+ if cache.contains_key(&key) {
+ continue;
+ }
+ cache.insert(key.clone(), None);
+ let cache = self.retimed_sound.clone();
+ std::thread::spawn(move || {
+ if let Ok(pcm) = ffmpeg::decode_audio_retimed(&path, rate, from, span, speed, reverse)
+ && let Some(slot) = cache.lock().unwrap().get_mut(&key)
+ {
+ *slot = Some(Arc::new(pcm));
+ }
+ });
+ }
+ }
+
fn say(&mut self, text: impl Into) {
self.status = Some(Status {
text: text.into(),
@@ -879,6 +984,12 @@ impl Editor {
});
}
Hit::Gutter(_, None) => {}
+ // A transition's tab: the clip it leads into, its transition open.
+ Hit::Transition(id) => {
+ self.selection = vec![id];
+ self.layout.inspector_open = true;
+ self.folds.insert((id, "Transition"), true);
+ }
Hit::Beat(id) => {
self.select_beat(Some(id), cx);
if event.click_count >= 2 {
@@ -1400,43 +1511,57 @@ impl Editor {
Ok(plan) => plan,
Err(e) => return Err(format!("Cannot export: {e}")),
};
- let mut command = ffmpeg::export_command().map_err(|e| e.to_string())?;
- let child = command
- .args(&plan.args)
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn();
- let mut child = match child {
- Ok(child) => child,
- Err(e) => return Err(format!("Could not start ffmpeg: {e}")),
- };
- let stdout = child.stdout.take().unwrap();
- let mut stderr = child.stderr.take().unwrap();
+ // Started off the window's thread: reversed clips are made first.
let job = Arc::new(Mutex::new(ExportJob {
output: output.clone(),
- child: Some(child),
..Default::default()
}));
let shared = job.clone();
let duration = plan.duration;
std::thread::spawn(move || {
- let errors = std::thread::spawn(move || {
- let mut text = String::new();
- let _ = stderr.read_to_string(&mut text);
- text
- });
- for line in BufReader::new(stdout).lines().map_while(Result::ok) {
- if let Some(t) = export::parse_progress(&line) {
- shared.lock().unwrap().progress = (t as f32 / duration as f32).clamp(0.0, 1.0);
+ let result = (|| -> Result<(), String> {
+ let going = || !shared.lock().unwrap().cancelled;
+ ffmpeg::prepare(&plan, going).map_err(|e| e.to_string())?;
+ let mut command = ffmpeg::export_command().map_err(|e| e.to_string())?;
+ let mut child = command
+ .args(&plan.args)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("Could not start ffmpeg: {e}"))?;
+ let stdout = child.stdout.take().unwrap();
+ let mut stderr = child.stderr.take().unwrap();
+ {
+ let mut job = shared.lock().unwrap();
+ if job.cancelled {
+ let _ = child.kill();
+ return Err("cancelled".into());
+ }
+ job.child = Some(child);
}
- }
- let child = shared.lock().unwrap().child.take();
- let status = child.map(|mut c| c.wait());
- let errors = errors.join().unwrap_or_default();
- let result = match status {
- Some(Ok(s)) if s.success() => Ok(()),
- None => Err("cancelled".to_string()),
- _ => Err(errors.lines().last().unwrap_or("ffmpeg failed").to_string()),
+ let errors = std::thread::spawn(move || {
+ let mut text = String::new();
+ let _ = stderr.read_to_string(&mut text);
+ text
+ });
+ for line in BufReader::new(stdout).lines().map_while(Result::ok) {
+ if let Some(t) = export::parse_progress(&line) {
+ shared.lock().unwrap().progress = (t as f32 / duration as f32).clamp(0.0, 1.0);
+ }
+ }
+ let child = shared.lock().unwrap().child.take();
+ let status = child.map(|mut c| c.wait());
+ let errors = errors.join().unwrap_or_default();
+ match status {
+ Some(Ok(s)) if s.success() => Ok(()),
+ None => Err("cancelled".to_string()),
+ _ => Err(errors.lines().last().unwrap_or("ffmpeg failed").to_string()),
+ }
+ })();
+ ffmpeg::tidy(&plan);
+ let result = match result {
+ Err(_) if shared.lock().unwrap().cancelled => Err("cancelled".to_string()),
+ other => other,
};
shared.lock().unwrap().done = Some(result);
});
@@ -1488,6 +1613,7 @@ impl Editor {
}
let _ = std::fs::remove_file(&job.output);
job.child = None;
+ job.cancelled = true;
}
cx.notify();
}
diff --git a/crates/splice-app/src/editor/agent.rs b/crates/splice-app/src/editor/agent.rs
index ee406d9..a0116ee 100644
--- a/crates/splice-app/src/editor/agent.rs
+++ b/crates/splice-app/src/editor/agent.rs
@@ -105,9 +105,38 @@ fn look_json(look: &splice_core::look::Look) -> Value {
})),
"mask": look.mask,
"crop": look.crop,
+ "grade": (!look.grade.is_none()).then(|| json!({
+ "look": look.grade.preset.name(),
+ "exposure": look.grade.exposure,
+ "contrast": look.grade.contrast,
+ "saturation": look.grade.saturation,
+ "warmth": look.grade.temperature,
+ "tint": look.grade.tint,
+ })),
+ "keyframes": (!look.keys.is_empty()).then(|| keyframes_json(look)),
})
}
+/// Keyframes per property: clip time (seconds from its start), value, ease.
+fn keyframes_json(look: &splice_core::look::Look) -> Value {
+ let mut out = serde_json::Map::new();
+ for prop in splice_core::look::Prop::ALL {
+ let keys = look.keys.of(prop);
+ if keys.is_empty() {
+ continue;
+ }
+ out.insert(
+ prop.name().into(),
+ json!(
+ keys.iter()
+ .map(|k| json!({ "at": s(k.at), "value": k.value, "ease": k.ease.name() }))
+ .collect::>()
+ ),
+ );
+ }
+ Value::Object(out)
+}
+
fn kind(kind: MediaKind) -> &'static str {
match kind {
MediaKind::Video => "video",
@@ -158,6 +187,7 @@ impl Editor {
return self.ctl_plan(request, reply, cx);
}
"bible" => return self.ctl_bible(request, reply, cx),
+ "transition" | "speed" | "freeze" | "keyframe" => self.ctl_effects(cmd, request, cx),
"story" | "cue" | "view" => self.ctl_story(cmd, request, cx),
"record" => return self.ctl_record(request, reply, cx),
"symbol" | "placeholder" | "hold" => return self.ctl_symbol(cmd, request, reply, cx),
@@ -381,9 +411,14 @@ impl Editor {
.unwrap_or(0)
});
let mut look = splice_core::look::Look::default();
- if let Err(e) = apply_look(&mut look, request) {
+ if let Err(e) = apply_look(&mut look, request, 0) {
return Some(fail(e));
}
+ if !look.grade.is_none() {
+ return Some(fail(
+ "colour (--grade, --exposure…) is for video and pictures; a title or colour clip takes its own --color",
+ ));
+ }
let id = self.edit(cx, |p| {
let id = p.insert_generated(generator, track, at, duration);
p.set_look(id, |l| *l = look);
@@ -429,9 +464,20 @@ impl Editor {
self.edited_with(json!({ "clips": clips }))
}
"look" => {
- let ids = match self.clips(request) {
- Ok(ids) => ids,
- Err(e) => return Some(fail(e)),
+ // `--all`: every video and picture, for one colour across the movie.
+ let ids = if flag("all") == Some(true) {
+ let project = &self.doc.project;
+ project
+ .clips()
+ .filter(|(_, c)| c.generator.is_none())
+ .filter(|(_, c)| project.media(c.media).is_some_and(|m| m.is_visual()))
+ .map(|(_, c)| c.id)
+ .collect()
+ } else {
+ match self.clips(request) {
+ Ok(ids) => ids,
+ Err(e) => return Some(fail(e)),
+ }
};
let mut looks = Vec::new();
for &id in &ids {
@@ -439,12 +485,18 @@ impl Editor {
continue;
};
let mut look = clip.look.clone();
- if let Err(e) = apply_look(&mut look, request) {
+ let at = (self.player.position - clip.start).clamp(0, clip.duration);
+ if let Err(e) = apply_look(&mut look, request, at) {
return Some(fail(e));
}
if look.key.is_some() && clip.generator.is_some() {
return Some(fail("a green-screen key is for video and pictures, not text or colour"));
}
+ if look.grade != clip.look.grade && clip.generator.is_some() {
+ return Some(fail(
+ "colour (--grade, --exposure…) is for video and pictures; a title or colour clip takes its own --color",
+ ));
+ }
looks.push((id, look));
}
self.edit(cx, |p| {
@@ -805,6 +857,24 @@ impl Editor {
if clip.look != splice_core::look::Look::default() {
value["look"] = look_json(&clip.look);
}
+ if clip.frozen {
+ value["frozen"] = json!(true);
+ }
+ if clip.speed != 1.0 {
+ value["speed"] = json!(clip.speed);
+ }
+ if clip.reverse {
+ value["reverse"] = json!(true);
+ }
+ if let Some(cut) = project.cut_into(clip.id) {
+ value["transition"] = json!({
+ "kind": cut.kind.name(),
+ "duration": s(cut.duration()),
+ "from": cut.from.map(|c| c.0),
+ "start": s(cut.start),
+ "end": s(cut.end),
+ });
+ }
value
}
@@ -948,15 +1018,19 @@ impl Editor {
}
/// Look fields from a request, onto `look`.
-fn apply_look(look: &mut splice_core::look::Look, request: &Value) -> Result<(), String> {
+/// `at` is clip time, where a keyframed value is keyed.
+fn apply_look(look: &mut splice_core::look::Look, request: &Value, at: Time) -> Result<(), String> {
use splice_core::look::{Drift, Key, Mask, MotionKind, Shape};
let text = |key: &str| request.get(key).and_then(Value::as_str);
let number = |key: &str| request.get(key).and_then(Value::as_f64).map(|v| v as f32);
let flag = |key: &str| request.get(key).and_then(Value::as_bool);
- look.x = number("x").unwrap_or(look.x);
- look.y = number("y").unwrap_or(look.y);
- look.scale = number("scale").map_or(look.scale, |v| v.clamp(0.01, 20.0));
- look.opacity = number("opacity").map_or(look.opacity, |v| v.clamp(0.0, 1.0));
+ // A keyframed value takes a keyframe at `at`; the rest change throughout.
+ use splice_core::look::Prop;
+ for prop in Prop::ALL {
+ if let Some(v) = number(prop.name()) {
+ look.set_value(prop, at, v);
+ }
+ }
for (key, motion) in [("enter", &mut look.enter), ("exit", &mut look.exit)] {
if let Some(kind) = text(key) {
motion.kind = MotionKind::parse(kind).ok_or_else(|| {
@@ -1015,6 +1089,19 @@ fn apply_look(look: &mut splice_core::look::Look, request: &Value) -> Result<(),
mask.feather = number("feather").map_or(mask.feather, |v| v.clamp(0.001, 1.0));
mask.invert = flag("invert").unwrap_or(mask.invert);
}
+ if let Some(name) = text("grade") {
+ look.grade.preset = splice_core::look::Preset::parse(name).ok_or_else(|| {
+ let names: Vec<_> = splice_core::look::Preset::ALL.iter().map(|p| p.name()).collect();
+ format!("`grade` is one of {}", names.join(", "))
+ })?;
+ }
+ let grade = &mut look.grade;
+ grade.exposure = number("exposure").unwrap_or(grade.exposure);
+ grade.contrast = number("contrast").unwrap_or(grade.contrast);
+ grade.saturation = number("saturation").unwrap_or(grade.saturation);
+ grade.temperature = number("warmth").unwrap_or(grade.temperature);
+ grade.tint = number("tint").unwrap_or(grade.tint);
+ *grade = grade.clamped();
if text("crop") == Some("none") {
look.crop = None;
}
diff --git a/crates/splice-app/src/editor/effects.rs b/crates/splice-app/src/editor/effects.rs
new file mode 100644
index 0000000..9d131cf
--- /dev/null
+++ b/crates/splice-app/src/editor/effects.rs
@@ -0,0 +1,392 @@
+//! Commands for transitions, speed and keyframes: what the menu and keys do
+//! beyond the inspector's own controls.
+
+use super::*;
+use crate::actions::{AddKeyframe, AddTransition, FreezeFrame, ReverseClip};
+use splice_core::look::{Ease, Prop};
+use splice_core::model::Clip;
+use splice_core::transition::{Kind, Transition};
+
+/// How long a frozen frame holds when first made.
+const HOLD: Time = 2 * SECOND;
+
+impl Editor {
+ /// The clip a playhead command works on: the selected one under the
+ /// playhead, or else the topmost picture there.
+ fn clip_at_playhead(&self, want: impl Fn(&Clip) -> bool) -> Option {
+ let project = &self.doc.project;
+ let t = self.player.position;
+ self.selection
+ .iter()
+ .filter_map(|id| project.clip(*id))
+ .find(|c| c.contains(t) && want(c))
+ .or_else(|| {
+ project
+ .visual_stack(t)
+ .into_iter()
+ .rev()
+ .map(|(c, _)| c)
+ .find(|c| want(c))
+ })
+ .map(|c| c.id)
+ }
+
+ /// Holds the frame at the playhead for two seconds.
+ pub(super) fn freeze_frame_at_playhead(&mut self, cx: &mut Context) {
+ let t = self.player.position;
+ let video = |c: &Clip| {
+ c.generator.is_none()
+ && !c.frozen
+ && self
+ .doc
+ .project
+ .media(c.media)
+ .is_some_and(|m| m.kind == MediaKind::Video)
+ };
+ let Some(id) = self.clip_at_playhead(video) else {
+ return self.say_error("Put the playhead over a video clip to hold its frame");
+ };
+ let held = self.edit(cx, |p| p.freeze_frame(id, t, HOLD));
+ match held {
+ Some(held) => {
+ self.selection = vec![held];
+ self.say("Holding this frame for 2s; drag its end to hold it longer");
+ }
+ None => self.say_error("That frame could not be held"),
+ }
+ }
+
+ pub(super) fn freeze_frame_action(&mut self, _: &FreezeFrame, _: &mut Window, cx: &mut Context) {
+ self.freeze_frame_at_playhead(cx);
+ }
+
+ /// A dissolve into each selected clip, or else at the cut nearest the playhead.
+ pub(super) fn add_transition_action(&mut self, _: &AddTransition, _: &mut Window, cx: &mut Context) {
+ let project = &self.doc.project;
+ let visual = |id: ClipId| {
+ project
+ .clip(id)
+ .and_then(|c| project.source(c))
+ .is_some_and(|s| s.is_visual())
+ };
+ let mut targets: Vec = self
+ .selection
+ .iter()
+ .copied()
+ .filter(|id| visual(*id) && project.clip(*id).is_some_and(|c| c.transition.is_none()))
+ .collect();
+ if targets.is_empty() {
+ // The nearest cut between two clips, on a visible track.
+ let t = self.player.position;
+ let nearest = project
+ .tracks
+ .iter()
+ .filter(|lane| !lane.hidden)
+ .flat_map(|lane| lane.clips.iter())
+ .filter(|c| project.clip_before(c.id).is_some() && visual(c.id))
+ .min_by_key(|c| (c.start - t).abs());
+ match nearest {
+ Some(c) if c.transition.is_none() => targets.push(c.id),
+ Some(c) => {
+ self.selection = vec![c.id];
+ self.folds.insert((c.id, "Transition"), true);
+ self.layout.inspector_open = true;
+ self.say("That cut has a transition already");
+ return cx.notify();
+ }
+ None => return self.say_error("No cut between two clips to put a transition on"),
+ }
+ }
+ self.edit(cx, |p| {
+ p.set_transition(&targets, Some(Transition::new(Kind::Dissolve)))
+ });
+ self.selection = targets.clone();
+ for id in &targets {
+ self.folds.insert((*id, "Transition"), true);
+ }
+ self.layout.inspector_open = true;
+ self.say(match targets.len() {
+ 1 => "Dissolve added; choose another in the inspector".to_string(),
+ n => format!("Dissolves into {n} clips"),
+ });
+ }
+
+ /// Plays the selected clips backwards, or forwards again.
+ pub(super) fn reverse_action(&mut self, _: &ReverseClip, _: &mut Window, cx: &mut Context) {
+ let project = &self.doc.project;
+ let ids: Vec = self
+ .selection
+ .iter()
+ .copied()
+ .filter(|id| {
+ project
+ .clip(*id)
+ .filter(|c| c.generator.is_none() && !c.frozen)
+ .and_then(|c| project.media(c.media))
+ .is_some_and(|m| m.kind == MediaKind::Video || m.kind == MediaKind::Audio)
+ })
+ .collect();
+ if ids.is_empty() {
+ return self.say_error("Select a video or sound clip to play it backwards");
+ }
+ let back = !ids.iter().all(|id| project.clip(*id).is_some_and(|c| c.reverse));
+ self.edit(cx, |p| p.set_reverse(&ids, back));
+ self.say(if back { "Playing backwards" } else { "Playing forwards" });
+ }
+
+ /// Keyframes where and how big the selected picture is, at the playhead.
+ pub(super) fn add_keyframe_action(&mut self, _: &AddKeyframe, _: &mut Window, cx: &mut Context) {
+ let visual = |c: &Clip| self.doc.project.source(c).is_some_and(|s| s.is_visual());
+ let Some(id) = self.clip_at_playhead(visual) else {
+ return self.say_error("Put the playhead over a picture to keyframe it");
+ };
+ let Some(clip) = self.doc.project.clip(id) else { return };
+ let at = self.clip_time(clip);
+ self.edit(cx, |p| {
+ p.set_look(id, |look| {
+ for prop in [Prop::X, Prop::Y, Prop::Scale] {
+ let v = look.value(prop, at);
+ look.keys.set(prop, at, v, None);
+ }
+ });
+ });
+ self.selection = vec![id];
+ self.layout.inspector_open = true;
+ self.say("Keyframed its place and size here; move the playhead and drag it to animate");
+ }
+}
+
+impl Editor {
+ /// `splice ctl transition | speed | freeze | keyframe`.
+ pub(super) fn ctl_effects(
+ &mut self,
+ cmd: &str,
+ request: &serde_json::Value,
+ cx: &mut Context,
+ ) -> serde_json::Value {
+ use crate::ctl::fail;
+ use serde_json::{Value, json};
+ let text = |key: &str| request.get(key).and_then(Value::as_str);
+ let number = |key: &str| request.get(key).and_then(Value::as_f64);
+ let flag = |key: &str| request.get(key).and_then(Value::as_bool) == Some(true);
+ let named = match self.clips_of(request) {
+ Ok(ids) => ids,
+ Err(e) => return fail(e),
+ };
+ let reply = |this: &Self, ids: &[ClipId]| {
+ let clips: Vec = ids.iter().map(|id| this.clip_json(*id)).collect();
+ this.edited_with(json!({ "clips": clips }))
+ };
+ match cmd {
+ "transition" => {
+ let transition = if flag("none") {
+ None
+ } else {
+ let Some(name) = text("kind") else {
+ return fail(
+ "say which with --kind (dissolve, dip-black, wipe-left, push-left, zoom…) or --none",
+ );
+ };
+ let Some(kind) = Kind::parse(name) else {
+ let names: Vec<_> = Kind::ALL.iter().map(|k| k.name()).collect();
+ return fail(format!("`kind` is one of {}", names.join(", ")));
+ };
+ let duration = number("for").map_or(Transition::DEFAULT_LENGTH, splice_core::time::from_secs);
+ Some(Transition { kind, duration })
+ };
+ let project = &self.doc.project;
+ let ids: Vec = if flag("all") {
+ let track = self.track(request);
+ project
+ .tracks
+ .iter()
+ .enumerate()
+ .filter(|(i, _)| track.is_none_or(|t| t == *i))
+ .flat_map(|(_, lane)| lane.clips.iter())
+ .filter(|c| project.clip_before(c.id).is_some())
+ .map(|c| c.id)
+ .collect()
+ } else {
+ named
+ };
+ if ids.is_empty() {
+ return fail(if flag("all") {
+ "no cuts between two clips there"
+ } else {
+ "name the clip the transition leads into with --clip (or use --all)"
+ });
+ }
+ let pictures: Vec = ids
+ .iter()
+ .copied()
+ .filter(|id| {
+ project
+ .clip(*id)
+ .and_then(|c| project.source(c))
+ .is_some_and(|s| s.is_visual())
+ })
+ .collect();
+ if pictures.is_empty() {
+ return fail("transitions are for pictures: video, images, titles, colours, scenes");
+ }
+ self.edit(cx, |p| p.set_transition(&pictures, transition));
+ reply(self, &pictures)
+ }
+ "speed" => {
+ if named.is_empty() {
+ return fail("name clips with --clip");
+ }
+ let project = &self.doc.project;
+ for id in &named {
+ let clip = project.clip(*id).expect("named clips exist");
+ let ok = clip.generator.is_none()
+ && project.media(clip.media).is_some_and(|m| {
+ m.is_timed() && !matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_)))
+ });
+ if !ok {
+ return fail(format!(
+ "clip {} has no speed: only video and sound play faster or slower (a symbol stretches: `symbol length`)",
+ id.0
+ ));
+ }
+ if clip.frozen {
+ return fail(format!("clip {} is a held frame; drag or trim it to hold longer", id.0));
+ }
+ }
+ let rate = number("rate");
+ if let Some(r) = rate
+ && !(splice_core::model::SLOWEST..=splice_core::model::FASTEST).contains(&r)
+ {
+ return fail(format!(
+ "`rate` is from {} to {}",
+ splice_core::model::SLOWEST,
+ splice_core::model::FASTEST
+ ));
+ }
+ let reverse = request.get("reverse").and_then(Value::as_bool);
+ if rate.is_none() && reverse.is_none() {
+ return fail("say how: --rate R and/or --reverse | --forwards");
+ }
+ self.edit(cx, |p| {
+ if let Some(r) = rate {
+ p.set_speed(&named, r);
+ }
+ if let Some(back) = reverse {
+ p.set_reverse(&named, back);
+ }
+ });
+ reply(self, &named)
+ }
+ "freeze" => {
+ let [id] = named[..] else {
+ return fail("name one video clip with --clip");
+ };
+ let at = match self.time(request, "at") {
+ Ok(t) => t.unwrap_or(self.player.position),
+ Err(e) => return fail(e),
+ };
+ let hold = number("for").map_or(HOLD, splice_core::time::from_secs);
+ match self.edit(cx, |p| p.freeze_frame(id, at, hold)) {
+ Some(held) => reply(self, &[held]),
+ None => fail("hold a frame of a video clip, at a time inside it"),
+ }
+ }
+ "keyframe" => {
+ let [id] = named[..] else {
+ return fail("name one clip with --clip");
+ };
+ let props: Vec = match text("prop") {
+ Some(name) => match Prop::parse(name) {
+ Some(p) => vec![p],
+ None => return fail("`prop` is x, y, scale or opacity"),
+ },
+ None => Prop::ALL.to_vec(),
+ };
+ let Some(clip) = self.doc.project.clip(id).cloned() else {
+ return fail("there is no such clip");
+ };
+ if flag("clear") {
+ let at = self.clip_time(&clip);
+ self.edit(cx, |p| {
+ p.set_look(id, |look| {
+ for prop in &props {
+ let v = look.value(*prop, at);
+ look.keys.of_mut(*prop).clear();
+ look.set_value(*prop, at, v);
+ }
+ })
+ });
+ return reply(self, &[id]);
+ }
+ let at = match self.time(request, "at") {
+ Ok(Some(t)) => t - clip.start,
+ Ok(None) => return fail("say when with --at T (timeline time)"),
+ Err(e) => return fail(e),
+ };
+ if at < 0 || at > clip.duration {
+ return fail(format!(
+ "--at is outside clip {} ({:.2}s to {:.2}s)",
+ id.0,
+ splice_core::time::secs(clip.start),
+ splice_core::time::secs(clip.end())
+ ));
+ }
+ if flag("drop") {
+ let mut gone = 0;
+ self.edit(cx, |p| {
+ p.set_look(id, |look| {
+ for prop in &props {
+ let v = look.value(*prop, at);
+ if look.keys.remove(*prop, at) {
+ gone += 1;
+ if look.keys.of(*prop).is_empty() {
+ look.set_value(*prop, at, v);
+ }
+ }
+ }
+ })
+ });
+ if gone == 0 {
+ return fail("no keyframe there");
+ }
+ return reply(self, &[id]);
+ }
+ let ease = match text("ease") {
+ Some(name) => match Ease::parse(name) {
+ Some(e) => Some(e),
+ None => return fail("`ease` is linear, in, out, in-out or hold"),
+ },
+ None => None,
+ };
+ let values: Vec<(Prop, f32)> = Prop::ALL
+ .iter()
+ .filter_map(|p| number(p.name()).map(|v| (*p, v as f32)))
+ .collect();
+ if values.is_empty() && ease.is_none() {
+ return fail("give values to keyframe: --x, --y, --scale, --opacity (and --ease)");
+ }
+ self.edit(cx, |p| {
+ p.set_look(id, |look| {
+ for (prop, v) in &values {
+ look.keys.set(*prop, at, *v, ease);
+ }
+ // An ease alone changes the keyframes already there.
+ if values.is_empty()
+ && let Some(e) = ease
+ {
+ for prop in &props {
+ for k in look.keys.of_mut(*prop) {
+ if (k.at - at).abs() <= splice_core::look::KEY_NEAR {
+ k.ease = e;
+ }
+ }
+ }
+ }
+ })
+ });
+ reply(self, &[id])
+ }
+ _ => fail(format!("unknown command {cmd}")),
+ }
+ }
+}
diff --git a/crates/splice-app/src/editor/inspector.rs b/crates/splice-app/src/editor/inspector.rs
index 30e59ac..b93cdb4 100644
--- a/crates/splice-app/src/editor/inspector.rs
+++ b/crates/splice-app/src/editor/inspector.rs
@@ -6,8 +6,9 @@ use super::render::{caps, icon, tip};
use super::stage::Grab;
use super::*;
use gpui::{Hsla, Stateful};
-use splice_core::look::{Drift, Key, Mask, MotionKind, Shape};
+use splice_core::look::{Drift, Ease, KEY_NEAR, Key, Mask, MotionKind, Preset, Prop, Shape};
use splice_core::model::{Align, Clip, Source, Title};
+use splice_core::transition::{Kind as TransitionKind, Transition};
pub const INSPECTOR_WIDTH: f32 = 292.0;
@@ -33,9 +34,27 @@ pub enum Field {
CropBottom,
EnterDuration,
ExitDuration,
+ Exposure,
+ Contrast,
+ Saturation,
+ Warmth,
+ Tint,
+ Speed,
+ TransitionLength,
}
impl Field {
+ /// The keyframed property it shows, if it is one.
+ pub fn prop(self) -> Option {
+ match self {
+ Field::X => Some(Prop::X),
+ Field::Y => Some(Prop::Y),
+ Field::Scale => Some(Prop::Scale),
+ Field::Opacity => Some(Prop::Opacity),
+ _ => None,
+ }
+ }
+
fn range(self) -> (f32, f32) {
match self {
Field::X | Field::Y => (-0.75, 0.75),
@@ -50,6 +69,9 @@ impl Field {
Field::MaskFeather => (0.001, 1.0),
Field::CropLeft | Field::CropRight | Field::CropTop | Field::CropBottom => (0.0, 0.95),
Field::EnterDuration | Field::ExitDuration => (0.1, 3.0),
+ Field::Exposure | Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => (-1.0, 1.0),
+ Field::Speed => (0.1, 4.0),
+ Field::TransitionLength => (0.1, 3.0),
}
}
@@ -66,6 +88,9 @@ impl Field {
Field::MaskFeather => Mask::default().feather,
Field::CropLeft | Field::CropRight | Field::CropTop | Field::CropBottom => 0.0,
Field::EnterDuration | Field::ExitDuration => 0.5,
+ Field::Exposure | Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => 0.0,
+ Field::Speed => 1.0,
+ Field::TransitionLength => secs(Transition::DEFAULT_LENGTH) as f32,
}
}
@@ -88,7 +113,13 @@ impl Field {
Field::CropRight => "Right",
Field::CropTop => "Top",
Field::CropBottom => "Bottom",
- Field::EnterDuration | Field::ExitDuration => "Length",
+ Field::EnterDuration | Field::ExitDuration | Field::TransitionLength => "Length",
+ Field::Exposure => "Exposure",
+ Field::Contrast => "Contrast",
+ Field::Saturation => "Saturation",
+ Field::Warmth => "Warmth",
+ Field::Tint => "Tint",
+ Field::Speed => "Speed",
}
}
@@ -104,7 +135,10 @@ impl Field {
}
}
Field::TextSize => format!("{:.0} px", v * canvas_height as f32),
- Field::EnterDuration | Field::ExitDuration => format!("{v:.1}s"),
+ Field::EnterDuration | Field::ExitDuration | Field::TransitionLength => format!("{v:.1}s"),
+ Field::Exposure => format!("{v:+.1}"),
+ Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => format!("{:+.0}", v * 100.0),
+ Field::Speed => format!("{}×", crate::timeline::speed_label(v as f64)),
Field::X | Field::Y => format!("{:+.0}%", v * 100.0),
// Mask places count from the middle of the picture, like X and Y.
Field::MaskX | Field::MaskY => format!("{:+.0}%", (v - 0.5) * 100.0),
@@ -112,7 +146,8 @@ impl Field {
}
}
- fn get(self, clip: &Clip) -> f32 {
+ /// Its value for `clip`, `at` (clip time) for what keyframes animate.
+ fn get(self, clip: &Clip, at: Time) -> f32 {
let look = &clip.look;
let key = look.key.unwrap_or_default();
let mask = look.mask.unwrap_or_default();
@@ -120,11 +155,11 @@ impl Field {
Some(Generator::Text(t)) => t.size,
_ => Title::default().size,
};
+ if let Some(prop) = self.prop() {
+ return look.value(prop, at);
+ }
match self {
- Field::X => look.x,
- Field::Y => look.y,
- Field::Scale => look.scale,
- Field::Opacity => look.opacity,
+ Field::X | Field::Y | Field::Scale | Field::Opacity => unreachable!("keyed above"),
Field::Volume => clip.volume,
Field::TextSize => title,
Field::KeySimilarity => key.similarity,
@@ -140,16 +175,32 @@ impl Field {
Field::CropBottom => look.crop.map_or(0.0, |c| c.bottom),
Field::EnterDuration => secs(look.enter.duration) as f32,
Field::ExitDuration => secs(look.exit.duration) as f32,
+ Field::Exposure => look.grade.exposure,
+ Field::Contrast => look.grade.contrast,
+ Field::Saturation => look.grade.saturation,
+ Field::Warmth => look.grade.temperature,
+ Field::Tint => look.grade.tint,
+ Field::Speed => clip.speed as f32,
+ Field::TransitionLength => clip.transition.map_or(0.6, |t| secs(t.duration) as f32),
}
}
- fn set(self, clip: &mut Clip, v: f32) {
+ /// Sets it on `clip`; what keyframes animate gets a keyframe `at` (clip
+ /// time). Speed and transition lengths go through the project instead.
+ fn set(self, clip: &mut Clip, v: f32, at: Time) {
let look = &mut clip.look;
+ if let Some(prop) = self.prop() {
+ look.set_value(prop, at, v);
+ return;
+ }
match self {
- Field::X => look.x = v,
- Field::Y => look.y = v,
- Field::Scale => look.scale = v,
- Field::Opacity => look.opacity = v,
+ Field::X | Field::Y | Field::Scale | Field::Opacity => {}
+ Field::Speed | Field::TransitionLength => {}
+ Field::Exposure => look.grade.exposure = v,
+ Field::Contrast => look.grade.contrast = v,
+ Field::Saturation => look.grade.saturation = v,
+ Field::Warmth => look.grade.temperature = v,
+ Field::Tint => look.grade.tint = v,
Field::Volume => clip.volume = v,
Field::TextSize => {
if let Some(Generator::Text(t)) = &mut clip.generator {
@@ -271,8 +322,16 @@ impl Editor {
// Sliders.
fn slider(&self, id: ClipId, clip: &Clip, field: Field, cx: &mut Context) -> impl IntoElement + use<> {
- let (lo, hi) = field.range();
- let value = field.get(clip);
+ let (lo, hi) = self.field_range(field, clip);
+ let value = match field {
+ // As it plays: trims since can have shortened it.
+ Field::TransitionLength => self
+ .doc
+ .project
+ .cut_into(id)
+ .map_or_else(|| field.get(clip, 0), |c| secs(c.duration()) as f32),
+ _ => field.get(clip, self.clip_time(clip)),
+ };
let frac = ((value - lo) / (hi - lo)).clamp(0.0, 1.0);
let bounds = self.slider_bounds.clone();
let held = matches!(&self.grab, Some(Grab::Slider { field: f, .. }) if *f == field);
@@ -348,7 +407,11 @@ impl Editor {
cx.listener(move |this, event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
if event.click_count >= 2 {
- this.change(id, cx, |clip| field.set(clip, field.default()));
+ let before = this.doc.project.clone();
+ this.apply_field(id, field, field.default());
+ this.doc.commit(before);
+ this.after_edit();
+ cx.notify();
return;
}
this.grab = Some(Grab::Slider {
@@ -378,19 +441,53 @@ impl Editor {
return;
};
let Some(id) = self.inspected() else { return };
- let (lo, hi) = field.range();
+ let Some(clip) = self.doc.project.clip(id) else { return };
+ let (lo, hi) = self.field_range(field, clip);
let frac = (f32::from(at.x - bounds.left()) / f32::from(bounds.size.width).max(1.0)).clamp(0.0, 1.0);
let mut v = lo + frac * (hi - lo);
// Rest at the natural value when near it.
if (v - field.default()).abs() < (hi - lo) * 0.015 {
v = field.default();
}
- if let Some(clip) = self.doc.project.clip_mut(id) {
- field.set(clip, v);
- }
+ self.apply_field(id, field, v);
cx.notify();
}
+ /// A slider's range for this clip: a transition is no longer than its clips allow.
+ fn field_range(&self, field: Field, clip: &Clip) -> (f32, f32) {
+ let (lo, hi) = field.range();
+ match field {
+ Field::TransitionLength => {
+ let most = secs(self.doc.project.longest_transition(clip.id)) as f32;
+ (lo, hi.min(most).max(lo + 0.05))
+ }
+ _ => (lo, hi),
+ }
+ }
+
+ /// Sets a slider's value on clip `id`, not yet an undo step.
+ pub(super) fn apply_field(&mut self, id: ClipId, field: Field, v: f32) {
+ match field {
+ Field::Speed => self.doc.project.set_speed(&[id], v as f64),
+ Field::TransitionLength => {
+ if let Some(t) = self.doc.project.clip(id).and_then(|c| c.transition) {
+ let length = splice_core::time::from_secs(v as f64);
+ self.doc
+ .project
+ .set_transition(&[id], Some(Transition { duration: length, ..t }));
+ }
+ }
+ _ => {
+ let Some(at) = self.doc.project.clip(id).map(|c| self.clip_time(c)) else {
+ return;
+ };
+ if let Some(clip) = self.doc.project.clip_mut(id) {
+ field.set(clip, v, at);
+ }
+ }
+ }
+ }
+
// Small controls.
pub(super) fn chip(
@@ -696,6 +793,15 @@ impl Editor {
seconds(clip.start)
);
+ // What its media lets it do: change colour (any picture of media),
+ // change speed (video and sound, not a symbol, which stretches instead).
+ let (graded, timed) = match project.source(&clip) {
+ Some(Source::Media(m)) => (
+ m.is_visual(),
+ m.is_timed() && !matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_))),
+ ),
+ _ => (false, false),
+ };
let mut sections: Vec = Vec::new();
let symbol = project
.media(clip.media)
@@ -788,7 +894,16 @@ impl Editor {
}
if visual {
sections.push(self.place_section(id, &clip, cx).into_any_element());
+ sections.push(self.transition_section(id, &clip, cx).into_any_element());
sections.push(self.motion_section(id, &clip, cx).into_any_element());
+ if graded {
+ sections.push(self.colour_section(id, &clip, cx).into_any_element());
+ }
+ }
+ if timed {
+ sections.push(self.speed_section(id, &clip, cx).into_any_element());
+ }
+ if visual {
if media_clip {
sections.push(self.crop_section(id, &clip, cx).into_any_element());
sections.push(self.key_section(id, &clip, cx).into_any_element());
@@ -1082,13 +1197,429 @@ impl Editor {
.text_size(px(11.0))
.line_height(px(16.0))
.text_color(Theme::overlay())
- .child("Drag it on the stage, or its corners to resize."),
+ .child("Drag it on the stage, or its corners to resize. ◆ keys a value at the playhead."),
),
)
- .child(self.slider(id, clip, Field::Scale, cx))
- .child(self.slider(id, clip, Field::Opacity, cx))
- .child(self.slider(id, clip, Field::X, cx))
- .child(self.slider(id, clip, Field::Y, cx))
+ .child(self.keyed_slider(id, clip, Field::Scale, cx))
+ .child(self.keyed_slider(id, clip, Field::Opacity, cx))
+ .child(self.keyed_slider(id, clip, Field::X, cx))
+ .child(self.keyed_slider(id, clip, Field::Y, cx))
+ .when(!clip.look.keys.is_empty(), |d| d.child(self.keyframe_bar(id, clip, cx)))
+ .when(
+ !clip.look.keys.is_empty() && (self.player.position < clip.start || self.player.position > clip.end()),
+ |d| {
+ d.child(
+ div()
+ .text_size(px(10.5))
+ .line_height(px(15.0))
+ .text_color(Theme::overlay())
+ .child(
+ "The playhead is outside this clip: changes to animated values key its nearest end.",
+ ),
+ )
+ },
+ )
+ }
+
+ // Keyframes.
+
+ /// A slider with a keyframe button: filled when this value has a
+ /// keyframe at the playhead, hollow and bright when it is animated
+ /// elsewhere, faint when it is not animated.
+ fn keyed_slider(
+ &mut self,
+ id: ClipId,
+ clip: &Clip,
+ field: Field,
+ cx: &mut Context,
+ ) -> impl IntoElement + use<> {
+ let prop = field.prop().expect("a keyed field");
+ let at = self.clip_time(clip);
+ let keys = clip.look.keys.of(prop);
+ let here = keys.iter().any(|k| (k.at - at).abs() <= KEY_NEAR);
+ let animated = !keys.is_empty();
+ // Keyframes go where the playhead is, so it has to be in the clip.
+ let inside = self.player.position >= clip.start && self.player.position <= clip.end();
+ let color = if !inside {
+ Theme::surface2()
+ } else if here || animated {
+ Theme::accent()
+ } else {
+ Theme::overlay()
+ };
+ let button = div()
+ .id(SharedString::from(format!("key-{}", prop.name())))
+ .size(px(18.0))
+ .flex_none()
+ .rounded(px(4.0))
+ .flex()
+ .items_center()
+ .justify_center()
+ .cursor_pointer()
+ .hover(|s| s.bg(Theme::lane()))
+ .tooltip(tip(
+ if !inside {
+ "Move the playhead into this clip to keyframe it"
+ } else if here {
+ "Take away this keyframe"
+ } else if animated {
+ "Add a keyframe here"
+ } else {
+ "Animate: keyframe this value here"
+ },
+ None,
+ ))
+ .child(icon(if here { "key-on" } else { "key" }, color).size(px(11.0)))
+ .when(inside, |d| {
+ d.on_click(cx.listener(move |this, _, _, cx| this.toggle_key(id, prop, cx)))
+ });
+ div()
+ .flex()
+ .items_center()
+ .gap(px(4.0))
+ .child(button)
+ .child(div().flex_1().min_w_0().child(self.slider(id, clip, field, cx)))
+ }
+
+ /// Keyframes the value of `prop` at the playhead, or takes away the one there.
+ pub(super) fn toggle_key(&mut self, id: ClipId, prop: Prop, cx: &mut Context) {
+ let Some(clip) = self.doc.project.clip(id) else { return };
+ let at = self.clip_time(clip);
+ self.change(id, cx, |c| {
+ let value = c.look.value(prop, at);
+ if !c.look.keys.remove(prop, at) {
+ c.look.keys.set(prop, at, value, None);
+ } else if c.look.keys.of(prop).is_empty() {
+ // The last one gone, the value stays where it was.
+ c.look.set_value(prop, at, value);
+ }
+ });
+ }
+
+ /// Under the keyed sliders: step between keyframes, ease the one at the
+ /// playhead, or clear them all.
+ fn keyframe_bar(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> {
+ let at = self.clip_time(clip);
+ let moments = clip.look.keys.moments();
+ let start = clip.start;
+ let previous = moments.iter().rev().find(|m| **m < at - KEY_NEAR).copied();
+ let next = moments.iter().find(|m| **m > at + KEY_NEAR).copied();
+ let here = clip.look.keys.has_at(at);
+ let step = |name: &'static str, glyph: &str, to: Option, cx: &mut Context| {
+ let on = to.is_some();
+ div()
+ .id(name)
+ .size(px(22.0))
+ .rounded(px(5.0))
+ .flex()
+ .items_center()
+ .justify_center()
+ .when(on, |d| d.cursor_pointer().hover(|s| s.bg(Theme::lane())))
+ .child(icon(glyph, if on { Theme::text() } else { Theme::surface2() }).size(px(13.0)))
+ .when_some(to, |d, to| {
+ d.on_click(cx.listener(move |this, _, _, cx| this.seek(start + to, cx)))
+ })
+ .tooltip(tip(
+ if name == "key-prev" {
+ "Previous keyframe"
+ } else {
+ "Next keyframe"
+ },
+ None,
+ ))
+ .into_any_element()
+ };
+ let ease_now = Prop::ALL
+ .iter()
+ .flat_map(|p| clip.look.keys.of(*p).iter())
+ .find(|k| (k.at - at).abs() <= KEY_NEAR)
+ .map(|k| k.ease);
+ let eases: Vec = [
+ (Ease::Linear, "Linear"),
+ (Ease::InOut, "Smooth"),
+ (Ease::In, "In"),
+ (Ease::Out, "Out"),
+ (Ease::Hold, "Hold"),
+ ]
+ .into_iter()
+ .map(|(ease, label)| {
+ self.chip(
+ format!("ease-{}", ease.name()),
+ label,
+ ease_now == Some(ease),
+ cx,
+ move |this, cx| {
+ let Some(clip) = this.doc.project.clip(id) else { return };
+ let at = this.clip_time(clip);
+ this.change(id, cx, |c| {
+ for p in Prop::ALL {
+ for k in c.look.keys.of_mut(p) {
+ if (k.at - at).abs() <= KEY_NEAR {
+ k.ease = ease;
+ }
+ }
+ }
+ });
+ },
+ )
+ .flex_1()
+ .px(px(4.0))
+ .into_any_element()
+ })
+ .collect();
+ let count = moments.len();
+ div()
+ .pt(px(4.0))
+ .flex()
+ .flex_col()
+ .gap(px(6.0))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(4.0))
+ .child(step("key-prev", "chevron-left", previous, cx))
+ .child(
+ div()
+ .flex_1()
+ .text_size(px(11.0))
+ .text_color(Theme::subtext())
+ .child(if here {
+ format!("Keyframe at {}", seconds(at))
+ } else {
+ format!("{count} keyframe{}", if count == 1 { "" } else { "s" })
+ }),
+ )
+ .child(step("key-next", "chevron-right", next, cx))
+ .child(self.chip("keys-clear", "Clear", false, cx, move |this, cx| {
+ let Some(clip) = this.doc.project.clip(id) else { return };
+ let at = this.clip_time(clip);
+ this.change(id, cx, |c| {
+ // Each value stays as it is at the playhead.
+ for p in Prop::ALL {
+ let v = c.look.value(p, at);
+ c.look.keys.of_mut(p).clear();
+ c.look.set_value(p, at, v);
+ }
+ });
+ })),
+ )
+ .when(here, |d| {
+ d.child(
+ div()
+ .text_size(px(10.5))
+ .text_color(Theme::overlay())
+ .child("From this keyframe to the next:"),
+ )
+ .child(div().flex().gap(px(4.0)).children(eases))
+ })
+ }
+
+ // Transitions.
+
+ fn transition_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> {
+ let current = clip.transition;
+ let from_before = self.doc.project.clip_before(id).is_some();
+ let summary = match current {
+ Some(t) => format!("{} · {}", t.kind.label(), seconds(t.duration)),
+ None => "None".into(),
+ };
+ let (section, open) = self.fold(id, "Transition", current.is_some(), summary, cx);
+ let mut chips: Vec = vec![
+ self.chip("transition-none", "None", current.is_none(), cx, move |this, cx| {
+ this.edit(cx, |p| p.set_transition(&[id], None));
+ })
+ .into_any_element(),
+ ];
+ chips.extend(TransitionKind::ALL.into_iter().map(|kind| {
+ let on = current.is_some_and(|t| t.kind == kind);
+ self.chip(
+ format!("transition-{}", kind.name()),
+ kind.label(),
+ on,
+ cx,
+ move |this, cx| {
+ let length = this
+ .doc
+ .project
+ .clip(id)
+ .and_then(|c| c.transition)
+ .map_or(Transition::DEFAULT_LENGTH, |t| t.duration);
+ this.edit(cx, |p| {
+ p.set_transition(&[id], Some(Transition { kind, duration: length }));
+ });
+ },
+ )
+ .into_any_element()
+ }));
+ section.when(open, |d| {
+ d.child(div().flex().flex_wrap().gap(px(5.0)).children(chips))
+ .when(current.is_some(), |d| {
+ d.child(self.slider(id, clip, Field::TransitionLength, cx))
+ })
+ .child(
+ div()
+ .text_size(px(10.5))
+ .line_height(px(15.0))
+ .text_color(Theme::overlay())
+ .child(if from_before {
+ "From the clip before, centred on the cut. Frames past either clip's ends hold still."
+ } else {
+ "No clip ends right here, so it comes in over what is beneath."
+ }),
+ )
+ })
+ }
+
+ // Colour.
+
+ fn colour_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> {
+ let grade = clip.look.grade;
+ let (section, open) = self.fold(id, "Colour", !grade.is_none(), grade.summary(), cx);
+ let looks: Vec = Preset::ALL
+ .into_iter()
+ .map(|preset| {
+ self.chip(
+ format!("look-{}", preset.name()),
+ preset.label(),
+ grade.preset == preset,
+ cx,
+ move |this, cx| this.change(id, cx, |c| c.look.grade.preset = preset),
+ )
+ .into_any_element()
+ })
+ .collect();
+ section.when(open, |d| {
+ d.child(div().flex().flex_wrap().gap(px(5.0)).children(looks))
+ .child(self.slider(id, clip, Field::Exposure, cx))
+ .child(self.slider(id, clip, Field::Contrast, cx))
+ .child(self.slider(id, clip, Field::Saturation, cx))
+ .child(self.slider(id, clip, Field::Warmth, cx))
+ .child(self.slider(id, clip, Field::Tint, cx))
+ .child(
+ div()
+ .flex()
+ .gap(px(5.0))
+ .when(!grade.is_none(), |d| {
+ d.child(self.chip("grade-reset", "Reset", false, cx, move |this, cx| {
+ this.change(id, cx, |c| c.look.grade = Default::default());
+ }))
+ })
+ .child(
+ self.chip("grade-all", "Use on every clip", false, cx, move |this, cx| {
+ this.grade_everything_like(id, cx);
+ })
+ .tooltip(tip("Give every video and picture this colour", None)),
+ ),
+ )
+ })
+ }
+
+ /// Gives every video and picture clip the colour of clip `id`.
+ fn grade_everything_like(&mut self, id: ClipId, cx: &mut Context) {
+ let Some(grade) = self.doc.project.clip(id).map(|c| c.look.grade) else {
+ return;
+ };
+ let count = self.edit(cx, |p| {
+ let ids: Vec = p
+ .clips()
+ .filter(|(_, c)| c.generator.is_none())
+ .filter(|(_, c)| p.media(c.media).is_some_and(|m| m.is_visual()))
+ .map(|(_, c)| c.id)
+ .collect();
+ for clip in &ids {
+ p.set_look(*clip, |l| l.grade = grade);
+ }
+ ids.len()
+ });
+ self.say(format!("Every picture has this colour now ({count} clips)"));
+ }
+
+ // Speed.
+
+ fn speed_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> {
+ if clip.frozen {
+ let (section, open) = self.fold(id, "Speed", true, "Held frame".into(), cx);
+ return section
+ .when(open, |d| {
+ d.child(
+ div()
+ .text_size(px(11.0))
+ .line_height(px(16.0))
+ .text_color(Theme::overlay())
+ .child("One frame, held. Drag its end on the timeline to hold it longer."),
+ )
+ })
+ .into_any_element();
+ }
+ let video = self
+ .doc
+ .project
+ .media(clip.media)
+ .is_some_and(|m| m.kind == MediaKind::Video);
+ let speed = clip.speed;
+ let mut said = Vec::new();
+ if speed != 1.0 {
+ said.push(format!("{}×", crate::timeline::speed_label(speed)));
+ }
+ if clip.reverse {
+ said.push("Backwards".to_string());
+ }
+ let summary = if said.is_empty() {
+ "Normal".to_string()
+ } else {
+ said.join(" · ")
+ };
+ let (section, open) = self.fold(id, "Speed", !said.is_empty(), summary, cx);
+ let presets: Vec = [0.25, 0.5, 1.0, 1.5, 2.0, 4.0]
+ .into_iter()
+ .map(|v: f64| {
+ self.chip(
+ format!("speed-{v}"),
+ &format!("{}×", crate::timeline::speed_label(v)),
+ (speed - v).abs() < 1e-6,
+ cx,
+ move |this, cx| this.edit(cx, |p| p.set_speed(&[id], v)),
+ )
+ .flex_1()
+ .px(px(2.0))
+ .into_any_element()
+ })
+ .collect();
+ let inside = clip.contains(self.player.position);
+ section
+ .when(open, |d| {
+ d.child(div().flex().gap(px(4.0)).children(presets))
+ .child(self.slider(id, clip, Field::Speed, cx))
+ .child(
+ self.toggle("reverse", "Play backwards", clip.reverse, cx, move |this, cx| {
+ let back = this.doc.project.clip(id).is_some_and(|c| !c.reverse);
+ this.edit(cx, |p| p.set_reverse(&[id], back));
+ }),
+ )
+ .when(video, |d| {
+ d.child(
+ div().flex().child(
+ self.chip("freeze", "Freeze frame at the playhead", false, cx, move |this, cx| {
+ this.freeze_frame_at_playhead(cx);
+ })
+ .when(!inside, |c| c.opacity(0.5))
+ .tooltip(tip(
+ "Holds this frame for two seconds; what follows moves along",
+ Some("⌥F"),
+ )),
+ ),
+ )
+ })
+ .child(
+ div()
+ .text_size(px(10.5))
+ .line_height(px(15.0))
+ .text_color(Theme::overlay())
+ .child("Faster clips get shorter and what follows moves up; sound keeps its pitch."),
+ )
+ })
+ .into_any_element()
}
fn motion_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> {
diff --git a/crates/splice-app/src/editor/render.rs b/crates/splice-app/src/editor/render.rs
index bd9f89b..274ae10 100644
--- a/crates/splice-app/src/editor/render.rs
+++ b/crates/splice-app/src/editor/render.rs
@@ -328,6 +328,10 @@ impl Render for Editor {
.on_action(cx.listener(|this, _: &ShowSymbols, _, cx| this.show_shelf(Shelf::Symbols, cx)))
.on_action(cx.listener(Self::make_symbol_action))
.on_action(cx.listener(Self::break_apart_action))
+ .on_action(cx.listener(Self::add_transition_action))
+ .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(|_: &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/stage.rs b/crates/splice-app/src/editor/stage.rs
index 5872588..1c28db7 100644
--- a/crates/splice-app/src/editor/stage.rs
+++ b/crates/splice-app/src/editor/stage.rs
@@ -3,7 +3,8 @@
use super::*;
use gpui::{Hsla, Stateful};
-use splice_core::look::Pose;
+use splice_core::look::{Pose, Prop};
+use splice_core::model::Clip;
use splice_core::model::{Recipe, Source};
/// Something held with the mouse until it is let go. Changes land in the
@@ -53,6 +54,19 @@ pub struct Layer {
/// The part of the picture its mask lets through: left, top, right,
/// bottom, as fractions of its frame.
pub visible: [f32; 4],
+ /// The part a wipe shows now, the same way.
+ pub shows: [f32; 4],
+}
+
+/// What the stage paints, bottom first: pictures, and the colour of a dip.
+pub enum Paint {
+ Picture(Layer),
+ /// A colour: over the whole canvas, or over one clip's picture.
+ Fill {
+ color: u32,
+ opacity: f32,
+ over: Option,
+ },
}
/// What a mask lets through of a picture's frame.
@@ -109,14 +123,31 @@ fn corners(rect: Bounds) -> [Point; 4] {
}
impl Editor {
- /// The pictures under the playhead, bottom first.
- pub(super) fn layers(&mut self) -> Vec {
+ /// Where the playhead is in `clip`, from its start, kept inside it.
+ pub(super) fn clip_time(&self, clip: &Clip) -> Time {
+ (self.player.position - clip.start).clamp(0, clip.duration)
+ }
+
+ /// What is drawn at the playhead, bottom first, placed as export places it.
+ pub(super) fn layers(&mut self) -> Vec {
+ use splice_core::compose::Shown;
let project = &self.doc.project;
let canvas = (project.settings.width, project.settings.height);
let t = self.player.position;
let mut layers = Vec::new();
- for (clip, source) in project.visual_stack(t) {
- let pose = clip.look.pose(clip.duration, t - clip.start);
+ for shown in project.picture(t) {
+ let (drawn, pose, part) = match shown {
+ Shown::Clip { drawn, pose, part } => (*drawn, pose, part),
+ Shown::Fill { color, opacity, over } => {
+ layers.push(Paint::Fill {
+ color,
+ opacity: opacity as f32,
+ over,
+ });
+ continue;
+ }
+ };
+ let (clip, source) = (drawn.clip, drawn.source);
let (image, rendered) = match source {
Source::Media(media) => {
let filters = clip.look.pixel_filters();
@@ -144,7 +175,7 @@ impl Editor {
}
Source::Generated(_) => (natural, pose),
};
- layers.push(Layer {
+ layers.push(Paint::Picture(Layer {
clip: clip.id,
image,
natural,
@@ -162,7 +193,8 @@ impl Editor {
}
v
},
- });
+ shows: part,
+ }));
}
layers
}
@@ -242,9 +274,36 @@ impl Editor {
.inset_0();
// One element per picture, so each carries its own opacity.
- let pictures = layers.into_iter().map(|layer| {
+ let pictures = layers.into_iter().map(|paint| {
let cell = self.stage_bounds.clone();
let placed = placed.clone();
+ let layer = match paint {
+ Paint::Fill { color, opacity, over } => {
+ return div().absolute().inset_0().opacity(opacity.clamp(0.0, 1.0)).child(
+ canvas(
+ |_, _, _| {},
+ move |_, _, window, _| {
+ let stage = cell.get();
+ // Over one picture: where it was just placed.
+ let area = match over {
+ Some(id) => placed
+ .borrow()
+ .iter()
+ .rev()
+ .find(|(c, _, _)| *c == id)
+ .map(|(_, r, _)| r.intersect(&stage)),
+ None => Some(stage),
+ };
+ if let Some(area) = area {
+ window.paint_quad(gpui::fill(area, gpui::rgb(color)));
+ }
+ },
+ )
+ .size_full(),
+ );
+ }
+ Paint::Picture(layer) => layer,
+ };
let opacity = layer.pose.opacity.clamp(0.0, 1.0) as f32;
div().absolute().inset_0().opacity(opacity).child(
canvas(
@@ -252,10 +311,12 @@ impl Editor {
move |_, _, window, _| {
let stage = cell.get();
let rect = layer_rect(stage, canvas_size, layer.natural, &layer.pose);
- placed
- .borrow_mut()
- .push((layer.clip, sub_rect(rect, layer.visible), rect.center()));
- window.with_content_mask(Some(gpui::ContentMask { bounds: stage }), |window| {
+ // 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);
});
},
@@ -366,11 +427,12 @@ impl Editor {
if near && let Some(clip) = self.doc.project.clip(selected) {
let center = *center;
let from = f32::from((p.x - center.x).abs().max((p.y - center.y).abs())).max(1.0);
+ let at = self.clip_time(clip);
self.grab = Some(Grab::Scale {
clip: selected,
center,
from,
- start: clip.look.scale,
+ start: clip.look.value(Prop::Scale, at),
before: self.doc.project.clone(),
});
return cx.notify();
@@ -412,10 +474,11 @@ impl Editor {
window.prevent_default();
return cx.notify();
}
+ let at = self.clip_time(clip);
self.grab = Some(Grab::Move {
clip: id,
from: p,
- start: (clip.look.x, clip.look.y),
+ start: (clip.look.value(Prop::X, at), clip.look.value(Prop::Y, at)),
stage: (sw.max(1.0), sh.max(1.0)),
before: self.doc.project.clone(),
});
@@ -445,9 +508,10 @@ impl Editor {
y = 0.0;
}
let clip = *clip;
+ let at = self.doc.project.clip(clip).map_or(0, |c| self.clip_time(c));
self.doc.project.set_look(clip, |look| {
- look.x = x;
- look.y = y;
+ look.set_value(Prop::X, at, x);
+ look.set_value(Prop::Y, at, y);
});
}
Some(Grab::Scale {
@@ -460,7 +524,10 @@ impl Editor {
let reach = f32::from((at.x - center.x).abs().max((at.y - center.y).abs()));
let scale = (start * reach / from).clamp(0.05, 8.0);
let clip = *clip;
- self.doc.project.set_look(clip, |look| look.scale = scale);
+ let at = self.doc.project.clip(clip).map_or(0, |c| self.clip_time(c));
+ self.doc
+ .project
+ .set_look(clip, |look| look.set_value(Prop::Scale, at, scale));
}
Some(Grab::Slider { field, .. }) => {
let field = *field;
diff --git a/crates/splice-app/src/ffmpeg.rs b/crates/splice-app/src/ffmpeg.rs
index 2c481c3..314b2f7 100644
--- a/crates/splice-app/src/ffmpeg.rs
+++ b/crates/splice-app/src/ffmpeg.rs
@@ -10,8 +10,9 @@ use splice_core::time::{SECOND, Time, from_secs, secs};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
-use std::sync::{Arc, OnceLock};
+use std::sync::{Arc, Mutex, OnceLock};
const IMAGE_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "webp", "bmp", "tif", "tiff", "heic", "heif", "avif",
@@ -60,6 +61,35 @@ fn base_command() -> Result {
Ok(command)
}
+/// Runs what an export needs made first (reversed pieces), one at a time,
+/// stopping when `going` says to. Call `tidy` once the export is over.
+pub fn prepare(plan: &splice_core::export::Plan, going: impl Fn() -> bool) -> Result<()> {
+ if let Some(dir) = &plan.scratch {
+ std::fs::create_dir_all(dir)?;
+ }
+ for (path, text) in &plan.files {
+ std::fs::write(path, text)?;
+ }
+ for command in &plan.prepare {
+ if !going() {
+ bail!("cancelled");
+ }
+ let out = export_command()?.args(command).stdout(Stdio::null()).output()?;
+ if !out.status.success() {
+ let text = String::from_utf8_lossy(&out.stderr);
+ bail!("{}", text.lines().last().unwrap_or("could not reverse a clip"));
+ }
+ }
+ Ok(())
+}
+
+/// Removes what `prepare` made.
+pub fn tidy(plan: &splice_core::export::Plan) {
+ if let Some(dir) = &plan.scratch {
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
+
pub fn export_command() -> Result {
Ok(Command::new(ffmpeg()?))
}
@@ -189,6 +219,36 @@ pub fn still(path: &Path, at: Time, width: u32, height: u32, filters: Option<&st
.stderr(Stdio::null())
.output()?;
let expected = (width * height * 4) as usize;
+ if output.stdout.len() < expected {
+ // Past the picture's end (its sound runs on): the last frame there is.
+ return last_still(path, at, width, height, filters);
+ }
+ let mut bytes = output.stdout;
+ bytes.truncate(expected);
+ render_image(width, height, bytes).ok_or_else(|| anyhow!("bad frame"))
+}
+
+/// The last frame at or before `at`: read backwards from just after it.
+fn last_still(path: &Path, at: Time, width: u32, height: u32, filters: Option<&str>) -> Result> {
+ let from = (at - SECOND).max(0);
+ let output = base_command()?
+ .args([
+ "-ss",
+ &format!("{:.6}", secs(from)),
+ "-t",
+ &format!("{:.6}", secs(at - from + SECOND / 10)),
+ ])
+ .arg("-i")
+ .arg(path)
+ .args(["-an", "-sn", "-vf"])
+ .arg(with_filters(
+ format!("reverse,trim=end_frame=1,scale={width}:{height}:flags=bilinear"),
+ filters,
+ ))
+ .args(["-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "bgra", "pipe:1"])
+ .stderr(Stdio::null())
+ .output()?;
+ let expected = (width * height * 4) as usize;
if output.stdout.len() < expected {
bail!("no frame at {:.2}s in {}", secs(at), file_name(path));
}
@@ -261,6 +321,68 @@ pub fn decode_audio(path: &Path, rate: u32) -> Result {
Ok(Pcm { rate, samples })
}
+/// The sound of `length` of `path` from `from`, played at `speed` (its
+/// pitch kept) and backwards when `reverse`: what a retimed clip sounds like.
+pub fn decode_audio_retimed(
+ path: &Path,
+ rate: u32,
+ from: Time,
+ length: Time,
+ speed: f64,
+ reverse: bool,
+) -> Result {
+ let mut filters = Vec::new();
+ if reverse {
+ filters.push("areverse".to_string());
+ }
+ // Each atempo stretches by at most a factor of two.
+ let mut rest = speed;
+ while rest < 0.5 - 1e-9 {
+ filters.push("atempo=0.5".into());
+ rest /= 0.5;
+ }
+ while rest > 2.0 + 1e-9 {
+ filters.push("atempo=2.0".into());
+ rest /= 2.0;
+ }
+ if (rest - 1.0).abs() > 1e-9 {
+ filters.push(format!("atempo={rest:.6}"));
+ }
+ let mut command = base_command()?;
+ if from > 0 {
+ command.args(["-ss", &format!("{:.6}", secs(from))]);
+ }
+ command
+ .args(["-t", &format!("{:.6}", secs(length))])
+ .arg("-i")
+ .arg(path);
+ if !filters.is_empty() {
+ command.args(["-af", &filters.join(",")]);
+ }
+ let output = command
+ .args([
+ "-vn",
+ "-sn",
+ "-ac",
+ "2",
+ "-ar",
+ &rate.to_string(),
+ "-f",
+ "s16le",
+ "pipe:1",
+ ])
+ .stderr(Stdio::null())
+ .output()?;
+ let samples = output
+ .stdout
+ .as_chunks::<2>()
+ .0
+ .iter()
+ .map(|b| i16::from_le_bytes(*b))
+ .collect();
+ Ok(Pcm { rate, samples })
+}
+
/// Loudness per `bucket` of time, 0–255, for waveforms.
pub fn peaks(pcm: &Pcm, bucket: Time) -> Vec {
let per = ((pcm.rate as i64 * bucket / SECOND) as usize).max(1) * 2;
@@ -277,16 +399,29 @@ pub fn peaks(pcm: &Pcm, bucket: Time) -> Vec {
/// A running decoder: frames of one source from a start time onwards, at a
/// fixed rate and size. Dropping it stops ffmpeg.
pub struct FrameStream {
- child: Child,
+ /// The decoder now running; a stream going backwards runs one per piece.
+ child: Arc>>,
+ stop: Arc,
frames: Receiver>,
/// Source time of the first frame.
pub origin: Time,
pub fps: u32,
+ /// Source seconds per second of playback.
+ pub speed: f64,
+ /// Frames come from later to earlier source.
+ pub reverse: bool,
/// Frames taken so far.
pub taken: u64,
}
+/// How much source each backwards piece decodes: a piece is held whole to
+/// be reversed, so pieces stay short.
+const REVERSE_PIECE: Time = SECOND;
+
impl FrameStream {
+ /// Frames of `path` from source time `from`, one per frame of playback
+ /// at `fps`: `speed` source seconds a second, backwards when `reverse`.
+ #[allow(clippy::too_many_arguments)]
pub fn open(
path: &Path,
from: Time,
@@ -294,40 +429,98 @@ impl FrameStream {
width: u32,
height: u32,
filters: Option<&str>,
+ speed: f64,
+ reverse: bool,
) -> Result {
- let mut command = base_command()?;
- if from > 0 {
- command.args(["-ss", &format!("{:.6}", secs(from))]);
- }
- let mut child = command
- .arg("-i")
- .arg(path)
- .args(["-an", "-sn", "-vf"])
- .arg(with_filters(
- format!("fps={fps},scale={width}:{height}:flags=fast_bilinear"),
- filters,
- ))
- .args(["-f", "rawvideo", "-pix_fmt", "bgra", "pipe:1"])
- .stdout(Stdio::piped())
- .stderr(Stdio::null())
- .spawn()?;
- let stdout = child.stdout.take().unwrap();
+ let speed = if speed.is_finite() && speed > 0.0 { speed } else { 1.0 };
+ let vf = with_filters(
+ format!("setpts=(PTS-STARTPTS)/{speed:.6},fps={fps},scale={width}:{height}:flags=fast_bilinear"),
+ filters,
+ );
+ let decode = {
+ let path = path.to_path_buf();
+ let vf = vf.clone();
+ move |at: Time, length: Option, backwards: bool| -> Result {
+ let mut command = base_command()?;
+ if at > 0 {
+ command.args(["-ss", &format!("{:.6}", secs(at))]);
+ }
+ if let Some(length) = length {
+ command.args(["-t", &format!("{:.6}", secs(length))]);
+ }
+ let vf = if backwards { format!("{vf},reverse") } else { vf.clone() };
+ Ok(command
+ .arg("-i")
+ .arg(&path)
+ .args(["-an", "-sn", "-vf"])
+ .arg(vf)
+ .args(["-f", "rawvideo", "-pix_fmt", "bgra", "pipe:1"])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .spawn()?)
+ }
+ };
+ let child: Arc>> = Arc::new(Mutex::new(None));
+ let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = sync_channel(8);
- std::thread::Builder::new()
- .name("splice-decode".into())
- .spawn(move || pump(stdout, tx, width, height))?;
+ if !reverse {
+ let mut first = decode(from, None, false)?;
+ let stdout = first.stdout.take().unwrap();
+ *child.lock().unwrap() = Some(first);
+ std::thread::Builder::new()
+ .name("splice-decode".into())
+ .spawn(move || {
+ pump(stdout, &tx, width, height);
+ })?;
+ } else {
+ let (child, stop) = (child.clone(), stop.clone());
+ std::thread::Builder::new()
+ .name("splice-decode-back".into())
+ .spawn(move || {
+ // Each piece ends where the last began, latest first; the
+ // first ends a frame past `from`, so its frame comes first.
+ let mut top = from + SECOND / fps.max(1) as Time;
+ while top > 0 && !stop.load(Ordering::Relaxed) {
+ let bottom = (top - REVERSE_PIECE).max(0);
+ let Ok(mut piece) = decode(bottom, Some(top - bottom), true) else {
+ break;
+ };
+ let stdout = piece.stdout.take().unwrap();
+ if let Some(mut done) = child.lock().unwrap().replace(piece) {
+ let _ = done.wait();
+ }
+ if !pump(stdout, &tx, width, height) {
+ break;
+ }
+ top = bottom;
+ }
+ // A piece started as the stream was dropped is ended here.
+ if let Some(mut last) = child.lock().unwrap().take() {
+ let _ = last.kill();
+ let _ = last.wait();
+ }
+ })?;
+ }
Ok(FrameStream {
child,
+ stop,
frames: rx,
origin: from,
fps,
+ speed,
+ reverse,
taken: 0,
})
}
/// Source time of the next frame this stream will yield.
pub fn next_time(&self) -> Time {
- self.origin + (self.taken as i128 * SECOND as i128 / self.fps as i128) as Time
+ let along = (self.taken as f64 * SECOND as f64 * self.speed / self.fps as f64) as Time;
+ if self.reverse {
+ self.origin - along
+ } else {
+ self.origin + along
+ }
}
/// The next frame if it is decoded already.
@@ -338,26 +531,30 @@ impl FrameStream {
}
}
-fn pump(mut stdout: std::process::ChildStdout, tx: SyncSender>, width: u32, height: u32) {
+/// Passes frames on until the decoder ends; false when nobody wants more.
+fn pump(mut stdout: std::process::ChildStdout, tx: &SyncSender>, width: u32, height: u32) -> bool {
let size = (width * height * 4) as usize;
loop {
let mut buffer = vec![0u8; size];
if stdout.read_exact(&mut buffer).is_err() {
- return;
+ return true;
}
let Some(image) = render_image(width, height, buffer) else {
- return;
+ return true;
};
if tx.send(image).is_err() {
- return;
+ return false;
}
}
}
impl Drop for FrameStream {
fn drop(&mut self) {
- let _ = self.child.kill();
- let _ = self.child.wait();
+ self.stop.store(true, Ordering::Relaxed);
+ if let Some(mut child) = self.child.lock().unwrap().take() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
}
}
@@ -414,7 +611,7 @@ mod tests {
assert!(still(&path, SECOND, 32, 18, None).is_ok());
- let mut stream = FrameStream::open(&path, SECOND / 2, 30, 32, 18, None).unwrap();
+ let mut stream = FrameStream::open(&path, SECOND / 2, 30, 32, 18, None, 1.0, false).unwrap();
let start = std::time::Instant::now();
let mut frames = 0;
while frames < 10 && start.elapsed() < std::time::Duration::from_secs(5) {
@@ -425,6 +622,18 @@ mod tests {
assert_eq!(frames, 10);
assert_eq!(stream.next_time(), SECOND / 2 + 10 * SECOND / 30);
+ // Backwards, across pieces, and at another speed.
+ let mut back = FrameStream::open(&path, 3 * SECOND / 2, 30, 32, 18, None, 2.0, true).unwrap();
+ let start = std::time::Instant::now();
+ let mut frames = 0;
+ while frames < 20 && start.elapsed() < std::time::Duration::from_secs(5) {
+ if back.try_next().is_some() {
+ frames += 1;
+ }
+ }
+ assert_eq!(frames, 20, "the second piece follows the first");
+ assert_eq!(back.next_time(), 3 * SECOND / 2 - 20 * 2 * SECOND / 30);
+
assert_eq!(filmstrip(&path, probe.duration, 32, 18).unwrap().len(), 1);
let pcm = decode_audio(&path, 48_000).unwrap();
diff --git a/crates/splice-app/src/icons.rs b/crates/splice-app/src/icons.rs
index ca9eede..cd25d54 100644
--- a/crates/splice-app/src/icons.rs
+++ b/crates/splice-app/src/icons.rs
@@ -109,6 +109,19 @@ fn icon(name: &str) -> Option<&'static str> {
"layers" => {
r#" "#
}
+ // A keyframe: hollow where one could go, filled where one is.
+ "key" => r#" "#,
+ "key-on" => r#" "#,
+ "chevron-left" => r#" "#,
+ "speed" => {
+ r#" "#
+ }
+ "reverse" => r#" "#,
+ "freeze" => r#" "#,
+ "transition" => r#" "#,
+ "colour" => {
+ r#" "#
+ }
_ => return None,
})
}
diff --git a/crates/splice-app/src/player.rs b/crates/splice-app/src/player.rs
index 0dedde6..6d05908 100644
--- a/crates/splice-app/src/player.rs
+++ b/crates/splice-app/src/player.rs
@@ -6,7 +6,8 @@
use crate::ffmpeg::{self, FrameStream};
use crate::library::Library;
use gpui::RenderImage;
-use splice_core::model::{ClipId, Media, MediaId, MediaKind, Project, Source};
+use splice_core::compose;
+use splice_core::model::{Clip, ClipId, Media, MediaId, MediaKind, Project, Source};
use splice_core::time::{SECOND, Time, floor_to_frame};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -42,6 +43,9 @@ struct Shown {
struct Stream {
media: MediaId,
filters: Option,
+ /// Source seconds a second, and which way.
+ speed: f64,
+ reverse: bool,
size: (u32, u32),
decoder: FrameStream,
/// Decoded, not yet due.
@@ -57,6 +61,15 @@ struct StillResult {
image: Option>,
}
+/// A clip under the playhead whose frames need decoding.
+struct Wanted {
+ clip: Clip,
+ media: Media,
+ filters: Option,
+ /// The source time it shows.
+ source: Time,
+}
+
/// What the editor should do after a tick.
#[derive(Default)]
pub struct Tick {
@@ -213,42 +226,57 @@ impl Player {
let playing = self.is_playing();
let position = self.position;
- // Media that needs decoding: video, and pictures with a key or mask.
- let stack: Vec<_> = project
- .visual_stack(position)
+ // Media that needs decoding: video, and pictures with a key, mask or
+ // 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 stack: Vec = project
+ .picture(position)
.into_iter()
- .filter_map(|(c, source)| match source {
- Source::Media(m) => {
- let filters = c.look.pixel_filters();
- (m.kind == MediaKind::Video || filters.is_some()).then(|| (c.clone(), m.clone(), filters))
- }
- Source::Generated(_) => None,
+ .filter_map(|shown| match shown {
+ compose::Shown::Clip { drawn, .. } => match drawn.source {
+ Source::Media(m) => {
+ let clip = drawn.clip;
+ let filters = clip.look.pixel_filters();
+ 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::Generated(_) => None,
+ },
+ compose::Shown::Fill { .. } => None,
})
.collect();
- let visible: HashSet = stack.iter().map(|(c, _, _)| c.id).collect();
+ let visible: HashSet = stack.iter().map(|w| w.clip.id).collect();
let mut keep_streams = HashSet::new();
- for (clip, media, filters) in &stack {
- let source = if media.kind == MediaKind::Image {
- 0
- } else {
- floor_to_frame(clip.source_at(position), fps)
- };
- if playing && media.kind == MediaKind::Video {
+ for Wanted {
+ clip,
+ media,
+ filters,
+ source,
+ } in &stack
+ {
+ let source = *source;
+ // A held frame is a still, playing or not.
+ if playing && media.kind == MediaKind::Video && !clip.frozen {
keep_streams.insert(clip.id);
let Some(path) = library.path(&media.hash) else {
continue;
};
- self.ensure_stream(
- clip.id,
- media.id,
- filters,
- path,
- source,
- fps,
- (media.width, media.height),
- );
- if let Some((t, size, image)) = self.pull(clip.id, source + half_frame) {
+ self.ensure_stream(clip, media, filters, path, source, fps);
+ // 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 };
+ if let Some((t, size, image)) = self.pull(clip.id, due) {
self.show(clip.id, media.id, filters.clone(), t, size, image);
}
} else {
@@ -276,13 +304,17 @@ impl Player {
}
if playing {
- // Open streams for clips about to begin so they are ready on time.
- for (_, clip) in project.clips() {
- if clip.start > position && clip.start <= position + LOOKAHEAD {
- let Some(media) = project.media(clip.media).filter(|m| m.kind == MediaKind::Video) else {
+ // Open streams for clips about to be drawn so they are ready on time.
+ for (track, lane) in project.tracks.iter().enumerate().filter(|(_, l)| !l.hidden) {
+ for clip in &lane.clips {
+ let Some(drawn) = project.drawn(clip, track) else {
continue;
};
- if project.slot(clip.id).is_some_and(|s| project.tracks[s.track].hidden) {
+ if drawn.from <= position || drawn.from > position + LOOKAHEAD || clip.frozen {
+ continue;
+ }
+ let Source::Media(media) = drawn.source else { continue };
+ if media.kind != MediaKind::Video {
continue;
}
let Some(path) = library.path(&media.hash) else {
@@ -290,15 +322,8 @@ impl Player {
};
keep_streams.insert(clip.id);
let filters = clip.look.pixel_filters();
- self.ensure_stream(
- clip.id,
- media.id,
- &filters,
- path,
- clip.source_in,
- fps,
- (media.width, media.height),
- );
+ let source = clip.source_at(drawn.from).clamp(0, (media.duration - frame).max(0));
+ self.ensure_stream(clip, media, &filters, path, source, fps);
}
}
}
@@ -311,7 +336,7 @@ impl Player {
}
if let State::Priming(since) = self.state {
- let ready = stack.iter().all(|(c, _, _)| self.shown.contains_key(&c.id));
+ let ready = stack.iter().all(|w| self.shown.contains_key(&w.clip.id));
if ready || now - since > PRIME_TIMEOUT {
self.state = State::Playing {
anchor: now,
@@ -351,35 +376,40 @@ impl Player {
}
/// Keeps a stream for `clip` that will reach `source` soon, opening one if not.
- #[allow(clippy::too_many_arguments)]
fn ensure_stream(
&mut self,
- clip: ClipId,
- media: MediaId,
+ clip: &Clip,
+ media: &Media,
filters: &Option,
path: &std::path::Path,
source: Time,
fps: u32,
- size: (u32, u32),
) {
- if let Some(stream) = self.streams.get(&clip) {
+ let (speed, reverse) = (clip.speed, clip.reverse);
+ if let Some(stream) = self.streams.get(&clip.id) {
let next = stream.pending.as_ref().map_or(stream.decoder.next_time(), |(t, _)| *t);
- let usable = stream.media == media
+ // How far along the stream is towards `source`, in its direction.
+ let ahead = if reverse { source - next } else { next - source };
+ let usable = stream.media == media.id
&& stream.filters == *filters
- && next <= source + SECOND / 2
- && next >= source - SECOND;
+ && stream.speed == speed
+ && stream.reverse == reverse
+ && ahead <= (SECOND as f64 / 2.0 * speed) as Time
+ && ahead >= -(SECOND as f64 * speed) as Time;
if usable {
return;
}
}
- let (w, h) = self.decode_size(size);
- match FrameStream::open(path, source, fps, w, h, filters.as_deref()) {
+ let (w, h) = self.decode_size((media.width, media.height));
+ match FrameStream::open(path, source, fps, w, h, filters.as_deref(), speed, reverse) {
Ok(decoder) => {
self.streams.insert(
- clip,
+ clip.id,
Stream {
- media,
+ media: media.id,
filters: filters.clone(),
+ speed,
+ reverse,
size: (w, h),
decoder,
pending: None,
@@ -394,6 +424,7 @@ impl Player {
fn pull(&mut self, clip: ClipId, due: Time) -> Option<(Time, (u32, u32), Arc)> {
let stream = self.streams.get_mut(&clip)?;
let size = stream.size;
+ let reverse = stream.reverse;
let mut latest = None;
loop {
if stream.pending.is_none() {
@@ -401,7 +432,7 @@ impl Player {
stream.pending = stream.decoder.try_next().map(|image| (t, image));
}
match stream.pending.take() {
- Some((t, image)) if t <= due => latest = Some((t, image)),
+ Some((t, image)) if (if reverse { t >= due } else { t <= due }) => latest = Some((t, image)),
Some(early) => {
stream.pending = Some(early);
break;
diff --git a/crates/splice-app/src/symbols.rs b/crates/splice-app/src/symbols.rs
index 39d262e..e0b0f39 100644
--- a/crates/splice-app/src/symbols.rs
+++ b/crates/splice-app/src/symbols.rs
@@ -435,6 +435,11 @@ pub fn render(
let project = symbol.rendered(host);
let partial = out.with_extension("partial.mov");
let plan = export::plan_layer(&project, &resolver(project.settings), &partial).map_err(anyhow::Error::msg)?;
+ let prepared = ffmpeg::prepare(&plan, || true);
+ if prepared.is_err() {
+ ffmpeg::tidy(&plan);
+ }
+ prepared?;
let mut child = ffmpeg::export_command()?
.args(&plan.args)
.stdout(Stdio::piped())
@@ -456,7 +461,9 @@ pub fn render(
progress((t as f32 / duration as f32).clamp(0.0, 1.0));
}
}
- let status = child.wait()?;
+ let status = child.wait();
+ ffmpeg::tidy(&plan);
+ let status = status?;
let errors = errors.join().unwrap_or_default();
if !status.success() {
bail!("{}", errors.lines().last().unwrap_or("ffmpeg failed"));
diff --git a/crates/splice-app/src/timeline.rs b/crates/splice-app/src/timeline.rs
index 98b396d..b69c150 100644
--- a/crates/splice-app/src/timeline.rs
+++ b/crates/splice-app/src/timeline.rs
@@ -104,6 +104,8 @@ pub enum Hit {
StoryEmpty,
Gutter(usize, Option),
Body(ClipId),
+ /// The tab of the transition into a clip.
+ Transition(ClipId),
Start(ClipId),
End(ClipId),
Empty,
@@ -144,6 +146,14 @@ pub fn hit(project: &Project, view: &View, p: Point) -> Hit {
if track >= n || !within_row {
return Hit::Empty;
}
+ // A transition's tab sits over the cut, above the clips' edges.
+ for clip in &project.tracks[track].clips {
+ if let Some(cut) = project.cut_into(clip.id)
+ && transition_tab(view, &cut, y0).contains(&point(px(p.x), px(p.y)))
+ {
+ return Hit::Transition(clip.id);
+ }
+ }
for clip in &project.tracks[track].clips {
let (x0, x1) = (view.x(clip.start), view.x(clip.end()));
if p.x < x0 || p.x >= x1 {
@@ -161,6 +171,21 @@ pub fn hit(project: &Project, view: &View, p: Point) -> Hit {
Hit::Empty
}
+/// Where a transition's tab is drawn on a row at `y` (timeline coordinates):
+/// across the stretch it takes, at least wide enough to hold.
+pub fn transition_tab(view: &View, cut: &splice_core::transition::Cut, y: f32) -> Bounds {
+ let (a, z) = (view.x(cut.start), view.x(cut.end));
+ let width = (z - a).max(16.0);
+ let middle = (a + z) / 2.0;
+ bounds(point(px(middle - width / 2.0), px(y + 4.0)), size(px(width), px(16.0)))
+}
+
+/// A speed as people say it: 2, 0.5, 1.25.
+pub fn speed_label(speed: f64) -> String {
+ let s = format!("{speed:.2}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+}
+
/// The nearest snap target within reach of any of `candidates`, as a shift.
pub fn snap(view: &View, points: &[Time], candidates: &[Time]) -> Option<(Time, Time)> {
let reach = view.px_to_time(SNAP);
@@ -350,13 +375,24 @@ pub fn paint(b: Bounds, frame: Frame, window: &mut Window, cx: &mut App)
rect(window, cb, Theme::scrim(), 5.0);
}
// Name on a quiet backdrop.
- let label: String = if art.offline {
+ let mut label: String = if art.offline {
format!("{} (offline)", art.name)
} else if art.outdated {
format!("{} · update", art.name)
} else {
art.name.to_string()
};
+ // How it plays, when that differs.
+ if clip.frozen {
+ label.push_str(" · held");
+ } else {
+ if clip.speed != 1.0 {
+ label.push_str(&format!(" · {}×", speed_label(clip.speed)));
+ }
+ if clip.reverse {
+ label.push_str(" · reversed");
+ }
+ }
// An out-of-date use shows a dot in its corner.
if art.outdated && x1 - x0 > 16.0 {
rect(window, area(x1 - 11.0, y + 5.0, 6.0, 6.0), Theme::accent(), 3.0);
@@ -402,6 +438,67 @@ pub fn paint(b: Bounds, frame: Frame, window: &mut Window, cx: &mut App)
} else {
outline(window, cb, Theme::crust(), 1.0, 5.0);
}
+ // Keyframes of a chosen clip, as small marks along its foot.
+ if selected {
+ for k in clip.look.keys.moments() {
+ if k < 0 || k > clip.duration {
+ continue;
+ }
+ let kx = view.x(clip.start + k);
+ rect(window, area(kx - 3.5, y + ROW - 9.0, 7.0, 7.0), Theme::text(), 2.0);
+ outline(
+ window,
+ area(kx - 3.5, y + ROW - 9.0, 7.0, 7.0),
+ Theme::accent(),
+ 1.5,
+ 2.0,
+ );
+ }
+ }
+ }
+ // Transitions: a tab across each cut, the length they take.
+ for clip in &track.clips {
+ let Some(cut) = project.cut_into(clip.id) else { continue };
+ let tab = transition_tab(&view, &cut, y);
+ let (tx, tw) = (f32::from(tab.origin.x), f32::from(tab.size.width));
+ if tx + tw < GUTTER || tx > w {
+ continue;
+ }
+ let tab = bounds(at(tx, f32::from(tab.origin.y)), tab.size);
+ let chosen = frame.selection.contains(&clip.id);
+ rect(window, tab, if chosen { Theme::accent() } else { Theme::text() }, 4.0);
+ outline(window, tab, Theme::crust(), 1.0, 4.0);
+ let ink = Theme::ground();
+ let label = if tw > 58.0 {
+ cut.kind.label().to_string()
+ } else {
+ String::new()
+ };
+ if label.is_empty() {
+ // Two marks, like a bow tie.
+ rect(
+ window,
+ area(tx + tw / 2.0 - 3.0, f32::from(tab.origin.y - o.y) + 4.0, 2.0, 8.0),
+ ink,
+ 1.0,
+ );
+ rect(
+ window,
+ area(tx + tw / 2.0 + 1.0, f32::from(tab.origin.y - o.y) + 4.0, 2.0, 8.0),
+ ink,
+ 1.0,
+ );
+ } else {
+ text(
+ window,
+ cx,
+ &label,
+ at(tx + 6.0, f32::from(tab.origin.y - o.y) + 1.5),
+ 9.5,
+ ink,
+ ui.clone(),
+ );
+ }
}
}
// Nothing yet: say where it goes.
@@ -639,7 +736,7 @@ fn paint_clip_art(
}
let image = match art.kind {
Some(MediaKind::Video) => {
- let source = clip.source_in + view.px_to_time(tx - x0 + tile_w / 2.0);
+ let source = clip.source_at(clip.start + view.px_to_time(tx - x0 + tile_w / 2.0));
let index = art.strip.partition_point(|(t, _)| *t <= source).saturating_sub(1);
art.strip
.get(index)
@@ -668,14 +765,19 @@ fn paint_clip_art(
let mid = top + height / 2.0;
let mut x = visible_from;
while x < visible_to {
- let source = clip.source_in + view.px_to_time(x - x0);
- let span = view.px_to_time(2.0).max(PEAK_BUCKET);
+ // What plays here, backwards or at speed; a held frame is silent.
+ let (a, z) = (
+ clip.source_at(clip.start + view.px_to_time(x - x0)),
+ clip.source_at(clip.start + view.px_to_time(x + 2.0 - x0)),
+ );
+ let (source, span) = (a.min(z), (z - a).abs().max(PEAK_BUCKET));
let from = (source / PEAK_BUCKET).max(0) as usize;
let to = (((source + span) / PEAK_BUCKET) as usize)
.max(from + 1)
.min(peaks.len());
let peak = peaks.get(from..to).and_then(|s| s.iter().max()).copied().unwrap_or(0) as f32 / 255.0;
- let bar = (peak * clip.volume.min(1.5) * height).clamp(1.0, height);
+ let loud = if clip.frozen { 0.0 } else { clip.volume.min(1.5) };
+ let bar = (peak * loud * height).clamp(1.0, height);
rect(window, area(x, mid - bar / 2.0, 1.4, bar), Theme::wave(), 0.0);
x += 2.0;
}
diff --git a/crates/splice-core/src/compose.rs b/crates/splice-core/src/compose.rs
new file mode 100644
index 0000000..bd3950c
--- /dev/null
+++ b/crates/splice-core/src/compose.rs
@@ -0,0 +1,582 @@
+//! What the picture is made of, moment to moment: each visual clip's stretch
+//! of timeline (with the frames a transition borrows), its pose over time
+//! (its look, its keyframes and its transitions), the part of it a wipe
+//! shows, and the colours dips pass through. Export prints these as ffmpeg
+//! expressions; the viewer evaluates the same expressions.
+
+use crate::look::build::{C, T, add, clip01, div, mul, smooth, sub};
+use crate::look::{Expr, Pose, PoseExpr};
+use crate::model::{Clip, Project, Recipe, Source};
+use crate::time::{Time, secs};
+use crate::transition::{Cut, Kind};
+
+/// How far a push moves a picture: the canvas's width or height.
+const PUSH: f64 = 1.0;
+/// How much bigger a zoom makes the picture going out.
+const ZOOM: f64 = 0.25;
+/// The soft edge of a wipe, as a fraction of the picture.
+pub const WIPE_SOFT: f64 = 0.004;
+
+/// A side of a picture.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Edge {
+ Left,
+ Right,
+ Top,
+ Bottom,
+}
+
+/// The part of a picture a wipe shows: a band growing from `from` that
+/// covers `progress` (0 to 1) of it.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Reveal {
+ pub from: Edge,
+ /// Over clip time in seconds.
+ pub progress: Expr,
+ /// How far ahead of `progress` the band reaches, as a fraction: the
+ /// picture going out reaches one soft edge further than the one coming
+ /// in leaves it, so the soft edge never shows what is beneath.
+ pub lead: f64,
+}
+
+impl Reveal {
+ /// What shows at clip time `t`: left, top, right, bottom, as fractions.
+ pub fn part(&self, t: f64) -> [f32; 4] {
+ let e = self.progress.eval(t).clamp(0.0, 1.0) as f32;
+ match self.from {
+ Edge::Left => [0.0, 0.0, e, 1.0],
+ Edge::Right => [1.0 - e, 0.0, 1.0, 1.0],
+ Edge::Top => [0.0, 0.0, 1.0, e],
+ Edge::Bottom => [0.0, 1.0 - e, 1.0, 1.0],
+ }
+ }
+
+ /// 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 {
+ 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),
+ };
+ if grows_back {
+ format!("clip(({along}-1+{e})/{WIPE_SOFT:.6},0,1)")
+ } else {
+ format!("clip(({e}-{along})/{WIPE_SOFT:.6},0,1)")
+ }
+ }
+}
+
+/// A visual clip as it is drawn.
+#[derive(Clone, Debug)]
+pub struct Drawn<'a> {
+ pub clip: &'a Clip,
+ pub source: Source<'a>,
+ pub track: usize,
+ /// The stretch of timeline it is drawn over: its own, and any frames a
+ /// transition has it play before its start or after its end.
+ pub from: Time,
+ pub to: Time,
+ /// Its pose over clip time in seconds (negative before its start).
+ pub pose: PoseExpr,
+ /// The part of it a wipe shows, when one is under way.
+ pub reveal: Option,
+ /// The part of it a wipe out of it still shows.
+ pub conceal: Option,
+ /// Coming in from a colour (a dip with nothing before it): the colour,
+ /// over the stretch of timeline it clears.
+ pub tint: Option<(u32, Time, Time)>,
+}
+
+impl Drawn<'_> {
+ /// How long before its start it is drawn.
+ pub fn head(&self) -> Time {
+ self.clip.start - self.from
+ }
+
+ /// How long after its end it is drawn.
+ pub fn tail(&self) -> Time {
+ self.to - self.clip.end()
+ }
+
+ /// Its pose at timeline time `t`.
+ pub fn pose_at(&self, t: Time) -> Pose {
+ self.pose.at(secs(t - self.clip.start))
+ }
+
+ /// The part of its frame that shows at timeline time `t`.
+ pub fn shows(&self, t: Time) -> [f32; 4] {
+ let local = secs(t - self.clip.start);
+ let mut part: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
+ for reveal in [&self.reveal, &self.conceal].into_iter().flatten() {
+ let p = reveal.part(local);
+ part = [
+ part[0].max(p[0]),
+ part[1].max(p[1]),
+ part[2].min(p[2]),
+ part[3].min(p[3]),
+ ];
+ }
+ part
+ }
+}
+
+/// A colour a dip passes through, drawn over the clips of its cut.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Dip {
+ pub track: usize,
+ /// 0xRRGGBB.
+ pub color: u32,
+ pub from: Time,
+ pub to: Time,
+}
+
+impl Dip {
+ /// How much of it shows at timeline time `t`.
+ pub fn opacity(&self, t: Time) -> f64 {
+ let p = ((t - self.from) as f64 / (self.to - self.from).max(1) as f64).clamp(0.0, 1.0);
+ 1.0 - (2.0 * p - 1.0).abs()
+ }
+}
+
+/// How a clip sounds across transitions: when its sound plays (past its
+/// edges where a transition crossfades it with a neighbour's), and its fades.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Sound {
+ /// Timeline stretch it plays over.
+ pub from: Time,
+ pub to: Time,
+ /// Where in its source the sound at `from` is.
+ pub source: Time,
+ /// Rising, and falling, over these stretches of timeline.
+ pub fade_in: Option<(Time, Time)>,
+ pub fade_out: Option<(Time, Time)>,
+}
+
+impl Sound {
+ /// How loud it plays at timeline time `t`, 0 to 1: quarter-sine fades,
+ /// so a crossfade keeps the loudness even.
+ pub fn gain(&self, t: Time) -> f32 {
+ let ramp = |(a, z): (Time, Time), rising: bool| {
+ let p = ((t - a) as f64 / (z - a).max(1) as f64).clamp(0.0, 1.0);
+ let p = if rising { p } else { 1.0 - p };
+ (p * std::f64::consts::FRAC_PI_2).sin() as f32
+ };
+ self.fade_in.map_or(1.0, |f| ramp(f, true)) * self.fade_out.map_or(1.0, |f| ramp(f, false))
+ }
+}
+
+/// What the viewer draws at a moment, bottom first.
+#[derive(Clone, Debug)]
+pub enum Shown<'a> {
+ Clip {
+ drawn: Box>,
+ pose: Pose,
+ /// The part of its frame that shows.
+ part: [f32; 4],
+ },
+ Fill {
+ color: u32,
+ opacity: f64,
+ /// Over just this clip's picture (a clip coming in from a colour),
+ /// or the whole canvas.
+ over: Option,
+ },
+}
+
+/// Whether a clip hides everything beneath it wherever it is drawn: a
+/// picture of its own, with no key, mask, crop or see-through parts.
+fn solid(project: &Project, clip: &Clip) -> bool {
+ let look = &clip.look;
+ let plain = look.key.is_none()
+ && look.mask.is_none()
+ && look.crop.is_none_or(|c| c.is_none())
+ && look.opacity >= 1.0
+ && look.keys.opacity.is_empty();
+ plain
+ && match project.source(clip) {
+ Some(Source::Media(m)) => match &m.recipe {
+ Some(Recipe::Scene(scene)) => !scene.transparent,
+ Some(Recipe::Symbol(_)) => false,
+ _ => true,
+ },
+ Some(Source::Generated(crate::model::Generator::Color { color })) => color & 0xff == 0xff,
+ _ => false,
+ }
+}
+
+/// Whether a clip covers the whole canvas, all the way through its first
+/// moments: solid, unmoved, at least full size, its media the canvas's shape.
+fn covers(project: &Project, clip: &Clip) -> bool {
+ let look = &clip.look;
+ let still = look.keys.x.is_empty()
+ && look.keys.y.is_empty()
+ && look.keys.scale.is_empty()
+ && look.x == 0.0
+ && look.y == 0.0
+ && look.scale >= 0.999
+ && look.enter.kind == crate::look::MotionKind::None;
+ let shape = match project.source(clip) {
+ Some(Source::Media(m)) if m.width > 0 && m.height > 0 => {
+ let (w, h) = (project.settings.width as f64, project.settings.height as f64);
+ ((m.width as f64 / m.height as f64) - w / h.max(1.0)).abs() < 0.01
+ }
+ Some(Source::Generated(_)) => true,
+ _ => false,
+ };
+ still && shape && solid(project, clip)
+}
+
+/// Progress through a cut over clip time, 0 to 1, for a clip starting at `start`.
+fn progress(cut: &Cut, start: Time) -> Expr {
+ let at = secs(cut.start - start);
+ let d = secs(cut.duration()).max(1e-3);
+ clip01(div(sub(T, C(at)), C(d)))
+}
+
+impl Project {
+ /// How clip `clip` (on track `track`) is drawn, if it shows at all.
+ pub fn drawn<'a>(&'a self, clip: &'a Clip, track: usize) -> Option> {
+ let source = self.source(clip).filter(|s| s.is_visual())?;
+ let (head, tail) = self.handles(clip.id);
+ let mut pose = clip.look.pose_expr(clip.duration);
+ let mut reveal = None;
+ let mut conceal = None;
+ let mut tint = None;
+ // The way in.
+ if let Some(cut) = self.cut_into(clip.id) {
+ let p = progress(&cut, clip.start);
+ let e = smooth(p.clone());
+ let rest = sub(C(1.0), e.clone());
+ match cut.kind {
+ Kind::Dissolve => pose.opacity = mul(pose.opacity, p),
+ Kind::DipBlack | Kind::DipWhite if cut.from.is_some() => {}
+ // With nothing before: a solid picture comes up out of the
+ // colour; anything else (a title, a picture in picture) fades in,
+ // so the colour never covers the rest of the frame.
+ Kind::DipBlack | Kind::DipWhite if solid(self, clip) => {
+ tint = Some((cut.kind.dip().unwrap_or(0), cut.start, cut.end));
+ }
+ Kind::DipBlack | Kind::DipWhite => pose.opacity = mul(pose.opacity, p),
+ Kind::WipeLeft => {
+ reveal = Some(Reveal {
+ from: Edge::Right,
+ progress: e,
+ lead: 0.0,
+ })
+ }
+ Kind::WipeRight => {
+ reveal = Some(Reveal {
+ from: Edge::Left,
+ progress: e,
+ lead: 0.0,
+ })
+ }
+ Kind::WipeUp => {
+ reveal = Some(Reveal {
+ from: Edge::Bottom,
+ progress: e,
+ lead: 0.0,
+ })
+ }
+ Kind::WipeDown => {
+ reveal = Some(Reveal {
+ from: Edge::Top,
+ progress: e,
+ lead: 0.0,
+ })
+ }
+ Kind::PushLeft => pose.x = add(pose.x, mul(C(PUSH), rest)),
+ Kind::PushRight => pose.x = sub(pose.x, mul(C(PUSH), rest)),
+ Kind::PushUp => pose.y = add(pose.y, mul(C(PUSH), rest)),
+ Kind::PushDown => pose.y = sub(pose.y, mul(C(PUSH), rest)),
+ Kind::Zoom => {
+ pose.opacity = mul(pose.opacity, p);
+ pose.scale = mul(pose.scale, add(C(1.0), mul(C(ZOOM), rest)));
+ }
+ }
+ }
+ // The way out, into the clip after.
+ if let Some(cut) = self.cut_out_of(clip.id) {
+ let p = progress(&cut, clip.start);
+ let e = smooth(p.clone());
+ let gone = sub(C(1.0), e.clone());
+ // It stays solid under the clip coming in only when that one
+ // covers it entirely; otherwise it would vanish at the end.
+ let fades = !(solid(self, clip) && self.clip(cut.to).is_some_and(|next| covers(self, next)));
+ match cut.kind {
+ Kind::Dissolve if fades => pose.opacity = mul(pose.opacity, sub(C(1.0), p)),
+ Kind::Dissolve | Kind::DipBlack | Kind::DipWhite => {}
+ // What a wipe has not yet reached.
+ Kind::WipeLeft => {
+ conceal = Some(Reveal {
+ from: Edge::Left,
+ progress: gone,
+ lead: WIPE_SOFT,
+ })
+ }
+ Kind::WipeRight => {
+ conceal = Some(Reveal {
+ from: Edge::Right,
+ progress: gone,
+ lead: WIPE_SOFT,
+ })
+ }
+ Kind::WipeUp => {
+ conceal = Some(Reveal {
+ from: Edge::Top,
+ progress: gone,
+ lead: WIPE_SOFT,
+ })
+ }
+ Kind::WipeDown => {
+ conceal = Some(Reveal {
+ from: Edge::Bottom,
+ progress: gone,
+ lead: WIPE_SOFT,
+ })
+ }
+ Kind::PushLeft => pose.x = sub(pose.x, mul(C(PUSH), e)),
+ Kind::PushRight => pose.x = add(pose.x, mul(C(PUSH), e)),
+ Kind::PushUp => pose.y = sub(pose.y, mul(C(PUSH), e)),
+ Kind::PushDown => pose.y = add(pose.y, mul(C(PUSH), e)),
+ Kind::Zoom => {
+ pose.scale = mul(pose.scale, add(C(1.0), mul(C(ZOOM), e)));
+ if fades {
+ pose.opacity = mul(pose.opacity, sub(C(1.0), p));
+ }
+ }
+ }
+ }
+ Some(Drawn {
+ clip,
+ source,
+ track,
+ from: clip.start - head,
+ to: clip.end() + tail,
+ pose,
+ reveal,
+ conceal,
+ tint,
+ })
+ }
+
+ /// The dip into clip `id` from the clip before it, if its transition is one.
+ pub fn dip_into(&self, clip: &Clip, track: usize) -> Option {
+ let cut = self.cut_into(clip.id).filter(|c| c.from.is_some())?;
+ Some(Dip {
+ track,
+ color: cut.kind.dip()?,
+ from: cut.start,
+ to: cut.end,
+ })
+ }
+
+ /// How clip `clip` sounds: a transition between two clips crossfades
+ /// their sound across it (each playing on past its edge where the media
+ /// has more); a dip fades out and back in at the cut; a clip coming in
+ /// over nothing fades in.
+ pub fn sound(&self, clip: &Clip) -> Sound {
+ let mut sound = Sound {
+ from: clip.start,
+ to: clip.end(),
+ source: clip.source_in,
+ fade_in: None,
+ fade_out: None,
+ };
+ // Only a clip playing as recorded can borrow sound past its edges.
+ let length = self.media(clip.media).map_or(0, |m| m.duration);
+ let plain = !clip.is_retimed() && clip.generator.is_none();
+ if let Some(cut) = self.cut_into(clip.id) {
+ if cut.from.is_some() && cut.kind.dip().is_none() {
+ if plain {
+ sound.from = (clip.start - (clip.start - cut.start).min(clip.source_in)).max(cut.start);
+ sound.source = clip.source_in - (clip.start - sound.from);
+ }
+ sound.fade_in = Some((cut.start, cut.end));
+ } else if cut.from.is_some() {
+ sound.fade_in = Some((clip.start, cut.end));
+ } else {
+ sound.fade_in = Some((cut.start, cut.end));
+ }
+ }
+ if let Some(cut) = self.cut_out_of(clip.id) {
+ if cut.kind.dip().is_none() {
+ if plain {
+ let more = (length - clip.source_out()).max(0);
+ sound.to = clip.end() + (cut.end - clip.end()).min(more);
+ }
+ sound.fade_out = Some((cut.start, cut.end));
+ } else {
+ sound.fade_out = Some((cut.start, clip.end()));
+ }
+ }
+ sound
+ }
+
+ /// What the viewer draws at `t`, bottom first.
+ pub fn picture(&self, t: Time) -> Vec> {
+ let mut out = Vec::new();
+ for (track, lane) in self.tracks.iter().enumerate().filter(|(_, l)| !l.hidden) {
+ for clip in &lane.clips {
+ let Some(drawn) = self.drawn(clip, track) else { continue };
+ if t >= drawn.from && t < drawn.to {
+ let pose = drawn.pose_at(t);
+ let part = drawn.shows(t);
+ let tint = drawn.tint.filter(|(_, a, z)| t >= *a && t < *z);
+ out.push(Shown::Clip {
+ drawn: Box::new(drawn),
+ pose,
+ part,
+ });
+ if let Some((color, a, z)) = tint {
+ out.push(Shown::Fill {
+ color,
+ opacity: 1.0 - ((t - a) as f64 / (z - a).max(1) as f64).clamp(0.0, 1.0),
+ over: Some(clip.id),
+ });
+ }
+ }
+ if let Some(dip) = self.dip_into(clip, track)
+ && t >= dip.from
+ && t < dip.to
+ {
+ out.push(Shown::Fill {
+ color: dip.color,
+ opacity: dip.opacity(t),
+ over: None,
+ });
+ }
+ }
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::model::{Media, MediaId, MediaKind};
+ use crate::time::SECOND;
+ use crate::transition::Transition;
+ use std::path::PathBuf;
+
+ fn film(p: &mut Project) -> MediaId {
+ p.add_media(Media {
+ id: MediaId(0),
+ name: "v".into(),
+ hash: "a".into(),
+ ext: "mp4".into(),
+ kind: MediaKind::Video,
+ duration: 4 * SECOND,
+ width: 1920,
+ height: 1080,
+ has_audio: false,
+ source: PathBuf::new(),
+ recipe: None,
+ content: None,
+ })
+ }
+
+ #[test]
+ fn a_dissolve_draws_both_clips_across_the_cut() {
+ let mut p = Project::default();
+ let m = film(&mut p);
+ let a = p.append_clip(m).unwrap();
+ let b = p.append_clip(m).unwrap();
+ p.set_transition(&[b], Some(Transition::new(Kind::Dissolve)));
+ let mid = p.picture(4 * SECOND);
+ assert_eq!(mid.len(), 2, "the clip going out plays on at the cut");
+ let opacities: Vec = mid
+ .iter()
+ .map(|s| match s {
+ Shown::Clip { pose, .. } => pose.opacity,
+ Shown::Fill { .. } => -1.0,
+ })
+ .collect();
+ assert_eq!(opacities[0], 1.0, "a solid picture stays solid under the dissolve");
+ assert!((opacities[1] - 0.5).abs() < 1e-6, "halfway in: {opacities:?}");
+ let ids: Vec<_> = mid
+ .iter()
+ .filter_map(|s| match s {
+ Shown::Clip { drawn, .. } => Some(drawn.clip.id),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(ids, vec![a, b]);
+ assert_eq!(p.picture(4 * SECOND + 400_000).len(), 1, "past the transition");
+ }
+
+ #[test]
+ fn a_dip_fills_with_colour_at_the_cut() {
+ let mut p = Project::default();
+ let m = film(&mut p);
+ p.append_clip(m);
+ let b = p.append_clip(m).unwrap();
+ p.set_transition(&[b], Some(Transition::new(Kind::DipBlack)));
+ let at_cut = p.picture(4 * SECOND);
+ assert!(
+ matches!(at_cut.last(), Some(Shown::Fill { color: 0, opacity, over: None }) if (*opacity - 1.0).abs() < 1e-6),
+ "black at the cut"
+ );
+ assert_eq!(at_cut.len(), 2, "only the clip coming in, under the black");
+ }
+
+ #[test]
+ fn pushes_and_wipes_move_and_reveal() {
+ let mut p = Project::default();
+ let m = film(&mut p);
+ let a = p.append_clip(m).unwrap();
+ let b = p.append_clip(m).unwrap();
+ p.set_transition(&[b], Some(Transition::new(Kind::PushLeft)));
+ let shown = p.picture(4 * SECOND);
+ let xs: Vec = shown
+ .iter()
+ .filter_map(|s| match s {
+ Shown::Clip { pose, .. } => Some(pose.x),
+ _ => None,
+ })
+ .collect();
+ assert!((xs[0] + 0.5).abs() < 1e-6 && (xs[1] - 0.5).abs() < 1e-6, "{xs:?}");
+ p.set_transition(&[b], Some(Transition::new(Kind::WipeRight)));
+ let a_drawn = p.drawn(p.clip(a).unwrap(), 0).unwrap();
+ let b_drawn = p.drawn(p.clip(b).unwrap(), 0).unwrap();
+ assert_eq!(b_drawn.shows(4 * SECOND), [0.0, 0.0, 0.5, 1.0], "the left half is in");
+ assert_eq!(
+ a_drawn.shows(4 * SECOND),
+ [0.5, 0.0, 1.0, 1.0],
+ "the right half is still out"
+ );
+ assert!(b_drawn.reveal.unwrap().ffmpeg("t").starts_with("clip(("));
+ }
+
+ #[test]
+ fn sound_crossfades_across_a_dissolve_and_dips_at_a_dip() {
+ let mut p = Project::default();
+ let m = film(&mut p);
+ let a = p.append_clip(m).unwrap();
+ p.trim_end(a, 3 * SECOND);
+ let b = p.append_clip(m).unwrap();
+ // The second second of the media onwards, right after the first clip.
+ let clip = p.clip_mut(b).unwrap();
+ clip.source_in = SECOND;
+ clip.duration = 3 * SECOND;
+ p.set_transition(&[b], Some(Transition::new(Kind::Dissolve)));
+ let (out, into) = (p.sound(p.clip(a).unwrap()), p.sound(p.clip(b).unwrap()));
+ assert_eq!(
+ (out.to, into.from),
+ (3_300_000, 2_700_000),
+ "each plays on into the other"
+ );
+ assert_eq!(into.source, SECOND - 300_000);
+ let mid = 3 * SECOND;
+ let total = out.gain(mid).powi(2) + into.gain(mid).powi(2);
+ assert!((total - 1.0).abs() < 1e-3, "even loudness: {total}");
+ p.set_transition(&[b], Some(Transition::new(Kind::DipBlack)));
+ let (out, into) = (p.sound(p.clip(a).unwrap()), p.sound(p.clip(b).unwrap()));
+ assert_eq!((out.to, into.from), (3 * SECOND, 3 * SECOND), "a dip borrows nothing");
+ assert_eq!((out.gain(mid), into.gain(mid)), (0.0, 0.0), "silent at the cut");
+ }
+}
diff --git a/crates/splice-core/src/export.rs b/crates/splice-core/src/export.rs
index f23bf3f..73db651 100644
--- a/crates/splice-core/src/export.rs
+++ b/crates/splice-core/src/export.rs
@@ -9,7 +9,7 @@
use crate::look::MotionKind;
use crate::model::{Clip, Generator, Media, MediaKind, Project, Source};
-use crate::time::{Time, secs};
+use crate::time::{SECOND, Time, secs};
use std::path::{Path, PathBuf};
/// Overlapping loud clips are held under full scale instead of clipping: a
@@ -47,6 +47,22 @@ impl Quality {
pub struct Plan {
pub args: Vec,
pub duration: Time,
+ /// ffmpeg runs to make, one after another, before `args`: the pieces of
+ /// reversed clips, each reversed on its own so memory stays small.
+ pub prepare: Vec>,
+ /// Files to write before running (into `scratch`): the commands that
+ /// change a clip's opacity frame by frame.
+ pub files: Vec<(PathBuf, String)>,
+ /// Where those pieces go; remove it once the export is done.
+ pub scratch: Option,
+}
+
+/// Work done before the export proper, and where it puts its files.
+struct Prepare {
+ commands: Vec>,
+ files: Vec<(PathBuf, String)>,
+ dir: PathBuf,
+ used: bool,
}
/// A title or colour drawn by the app: a PNG at canvas resolution, with any
@@ -139,10 +155,12 @@ fn still(project: &Project, resolve: &impl Resolve, t: Time, output: &Path, see_
secs(project.duration())
));
}
+ // Every clip stays (one drawn on past its edges for a transition shows
+ // there too, and the transition needs its neighbours); build draws only
+ // what is drawn at `t`.
let mut moment = project.clone();
for track in &mut moment.tracks {
track.muted = true;
- track.clips.retain(|c| c.contains(t));
}
let mut plan = build(&moment, resolve, output, None, Some(t), see_through)?;
plan.duration = project.frame();
@@ -203,6 +221,12 @@ fn build(
]));
args.extend(strings(&["-f", "lavfi", "-t", &d, "-i", "anullsrc=r=48000:cl=stereo"]));
let mut next_input = 2usize;
+ let mut prepare = Prepare {
+ commands: Vec::new(),
+ files: Vec::new(),
+ dir: scratch_dir(),
+ used: false,
+ };
let mut filters: Vec = Vec::new();
let mut picture = "0:v".to_string();
@@ -211,128 +235,197 @@ fn build(
for (track_index, track) in project.tracks.iter().enumerate() {
for clip in &track.clips {
let source = project.source(clip).ok_or("a clip refers to missing media")?;
- let shows = !track.hidden && source.is_visual();
- let has_audio = matches!(source, Source::Media(m) if m.has_audio);
- let sounds_out = !track.muted && has_audio && clip.volume > 0.0;
- if !shows && !sounds_out {
- continue;
- }
- // For a still, each clip contributes one frame from `offset` into it.
- let offset = still_at.map_or(0, |t| t - clip.start);
- let length = if still_at.is_some() { frame } else { clip.duration };
- let placed_at = if still_at.is_some() { 0 } else { clip.start };
- let (start, len) = (fmt(placed_at), fmt(length));
-
- let mut rendered = None;
- match source {
- Source::Generated(generator) => {
- let picture = resolve
- .generated(clip, generator)
- .ok_or_else(|| format!("{} could not be drawn", generator.name()))?;
- let path = picture.path.to_string_lossy().to_string();
- rendered = Some((picture.width, picture.height));
- args.extend(strings(&[
- "-loop",
- "1",
- "-framerate",
- &fps.to_string(),
- "-t",
- &len,
- "-i",
- &path,
- ]));
- }
- Source::Media(media) => {
- let path = resolve
- .media(media)
- .ok_or_else(|| format!("{} is offline", media.name))?;
- let path = path.to_string_lossy().to_string();
- if media.kind == MediaKind::Image {
- args.extend(strings(&[
- "-loop",
- "1",
- "-framerate",
- &fps.to_string(),
- "-t",
- &len,
- "-i",
- &path,
- ]));
- } else {
- args.extend(strings(&[
- "-ss",
- &fmt(clip.source_in + offset),
- "-t",
- &len,
- "-i",
- &path,
- ]));
- }
- }
- }
- let i = next_input;
- next_input += 1;
+ // A still draws only what is there at that moment.
+ let drawn = (!track.hidden)
+ .then(|| project.drawn(clip, track_index))
+ .flatten()
+ .filter(|d| still_at.is_none_or(|t| t >= d.from && t < d.to));
+ let audio_media = match source {
+ Source::Media(m) if m.has_audio => Some(m),
+ _ => None,
+ };
+ let sounds_out =
+ still_at.is_none() && !track.muted && audio_media.is_some() && clip.volume > 0.0 && !clip.frozen;
let tag = format!("t{track_index}c{}", clip.id.0);
- if shows {
+ if let Some(drawn) = &drawn {
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`.
+ 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,
+ )?;
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 = look.pose_expr(clip.duration);
- let mut chain = vec![format!("fps={fps}")];
+ 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 {
+ let head = secs(drawn.head());
+ match still_at {
+ Some(t) => fmt(t - clip.start),
+ None if head > 0.0 => format!("({frame}/{fps}-{head:.6})"),
+ None => format!("({frame}/{fps})"),
+ }
+ };
+ let mut chain = Vec::new();
if matches!(source, Source::Media(_))
&& let Some(pixels) = look.pixel_filters()
{
chain.push(pixels);
}
chain.push("format=rgba".into());
- // A still knows its exact opacity; a movie fades over time.
- let opacity = match still_at {
- Some(_) => look.pose(clip.duration, offset).opacity as f32,
- None => look.opacity,
- };
- if opacity < 1.0 {
- chain.push(format!("colorchannelmixer=aa={:.4}", opacity.max(0.0)));
+ // 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)
+ )),
+ }
+ }
+ // 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)]
+ .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("*")
+ ));
}
- let animated =
- pose.x.constant().is_none() || pose.y.constant().is_none() || pose.scale.constant().is_none();
+ // 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)));
+ }
+ }
+ (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}"));
+ }
+ }
+ 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()));
- let (fw, fh) = (pw.max(w), ph.max(h));
- match pose.scale.constant() {
- Some(k) if !animated => {
- chain.push(format!("scale={}:{}", even((nw * k).round()), even((nh * k).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"));
}
}
- for (motion, inward) in [(look.enter, true), (look.exit, false)] {
- if motion.kind == MotionKind::None || still_at.is_some() {
- continue;
- }
- let fd = secs(motion.duration.min(clip.duration));
- let st = if inward { 0.0 } else { secs(clip.duration) - fd };
- chain.push(format!(
- "fade=t={}:st={st:.6}:d={fd:.6}:alpha=1",
- if inward { "in" } else { "out" }
- ));
- }
- if animated {
+ if moving {
// perspective has no clock of its own; its frame count is one.
- let t = if offset > 0 {
- format!("(in/{fps}+{})", fmt(offset))
- } else {
- format!("(in/{fps})")
- };
+ let t = local("in");
let k = format!("max(0.0001,{})", pose.scale.ffmpeg(&t));
let along = |by: f64| {
if by.abs() < 1e-6 {
@@ -352,6 +445,8 @@ fn build(
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).
@@ -359,34 +454,25 @@ fn build(
"format={},setpts=PTS-STARTPTS+{start}/TB",
if layer { "rgba" } else { "yuva420p" }
));
- filters.push(format!("[{i}:v]{}[v{tag}]", chain.join(",")));
+ filters.push(format!("[{frames}]{}[v{tag}]", chain.join(",")));
- let end = fmt(placed_at + length);
- let since = if placed_at > 0 {
- format!("(t-{start})")
- } else {
- "t".to_string()
- };
- let since = if offset > 0 {
- format!("({since}+{})", fmt(offset))
- } else {
- since
- };
+ 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 shift = |by: f64| {
- if by.abs() < 1e-6 {
- String::new()
- } else {
- format!("+{by:.3}*{}", pose.scale.ffmpeg(&since))
- }
- };
- // A moving picture has already been placed in its frame.
- let (x, y) = if animated {
+ 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+{}*W{}", pose.x.ffmpeg(&since), shift(cx)),
- format!("(H-h)/2+{}*H{}", pose.y.ffmpeg(&since), 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!(
@@ -396,14 +482,72 @@ fn build(
if layer { "format=auto:" } else { "" }
));
picture = format!("p{tag}");
+
+ if sounds_out && let (Some(shared), Some(media)) = (shared, audio_media) {
+ sounds.push(clip_sound(
+ &mut filters,
+ &mut args,
+ &mut next_input,
+ resolve,
+ clip,
+ media,
+ Some(shared),
+ project.sound(clip),
+ &tag,
+ )?);
+ }
+ } else if sounds_out && let Some(media) = audio_media {
+ sounds.push(clip_sound(
+ &mut filters,
+ &mut args,
+ &mut next_input,
+ resolve,
+ clip,
+ media,
+ None,
+ project.sound(clip),
+ &tag,
+ )?);
}
- if sounds_out {
- let delay = clip.start / 1000;
+
+ // A dip's colour, over the clips either side of its cut.
+ if let Some(dip) = (!track.hidden)
+ .then(|| project.dip_into(clip, track_index))
+ .flatten()
+ .filter(|d| still_at.is_none_or(|t| t >= d.from && t < d.to))
+ {
+ let len = if still_at.is_some() { frame } else { dip.to - dip.from };
+ args.extend(strings(&[
+ "-f",
+ "lavfi",
+ "-i",
+ &format!("color=c=0x{:06x}:s={w}x{h}:r={fps}:d={}", dip.color, fmt(len)),
+ ]));
+ let i = next_input;
+ next_input += 1;
+ let mut chain = vec!["format=rgba".to_string()];
+ match still_at {
+ Some(t) => chain.push(format!("colorchannelmixer=aa={:.4}", dip.opacity(t))),
+ None => {
+ let half = secs(len) / 2.0;
+ chain.push(format!("fade=t=in:st=0:d={half:.6}:alpha=1"));
+ chain.push(format!("fade=t=out:st={half:.6}:d={half:.6}:alpha=1"));
+ }
+ }
+ let placed = if still_at.is_some() { 0 } else { dip.from };
+ chain.push(format!(
+ "format={},setpts=PTS-STARTPTS+{}/TB",
+ if layer { "rgba" } else { "yuva420p" },
+ fmt(placed)
+ ));
+ filters.push(format!("[{i}:v]{}[d{tag}]", chain.join(",")));
filters.push(format!(
- "[{i}:a]aresample=48000,asetpts=PTS-STARTPTS,volume={:.3},adelay={delay}:all=1[a{tag}]",
- clip.volume
+ "[{picture}][d{tag}]overlay=x=0:y=0:{}eof_action=pass:enable='between(t,{},{})'[q{tag}]",
+ if layer { "format=auto:" } else { "" },
+ fmt(placed),
+ fmt(placed + len)
));
- sounds.push(format!("[a{tag}]"));
+ picture = format!("q{tag}");
}
}
}
@@ -436,7 +580,13 @@ fn build(
let Some(out) = out else {
args.extend(strings(&["-map", "[vout]", "-frames:v", "1", "-update", "1"]));
args.push(output.to_string_lossy().to_string());
- return Ok(Plan { args, duration });
+ return Ok(Plan {
+ args,
+ duration,
+ prepare: prepare.commands,
+ files: prepare.files,
+ scratch: prepare.used.then_some(prepare.dir),
+ });
};
let Out::Movie(quality, _) = out else {
args.extend(strings(&[
@@ -461,7 +611,13 @@ fn build(
"-nostats",
]));
args.push(output.to_string_lossy().to_string());
- return Ok(Plan { args, duration });
+ return Ok(Plan {
+ args,
+ duration,
+ prepare: prepare.commands,
+ files: prepare.files,
+ scratch: prepare.used.then_some(prepare.dir),
+ });
};
args.extend(strings(&[
"-map",
@@ -489,7 +645,348 @@ fn build(
"-nostats",
]));
args.push(output.to_string_lossy().to_string());
- Ok(Plan { args, duration })
+ Ok(Plan {
+ args,
+ duration,
+ prepare: prepare.commands,
+ files: prepare.files,
+ scratch: prepare.used.then_some(prepare.dir),
+ })
+}
+
+/// A clip's pictures, ready to place: the stream's label, the size of a
+/// picture the app drew, and the input its sound can share (with the source
+/// time that input starts at).
+struct Frames {
+ label: String,
+ rendered: Option<(u32, u32)>,
+ shared: Option<(usize, Time)>,
+}
+
+/// A fresh folder for an export's own files, in the system's temporary folder.
+fn scratch_dir() -> PathBuf {
+ use std::sync::atomic::{AtomicU64, Ordering};
+ static NEXT: AtomicU64 = AtomicU64::new(0);
+ std::env::temp_dir().join(format!(
+ "splice-export-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ))
+}
+
+/// The clear margin around a moving picture, in pixels each side.
+const BORDER: u32 = 2;
+
+/// 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;
+
+/// Adds the input (or inputs) a visual clip's pictures come from, and the
+/// filters that bring them to the frame rate over every moment it is drawn,
+/// from `drawn.from` on: played at its speed, backwards when reversed, held
+/// still where the media runs out.
+#[allow(clippy::too_many_arguments)]
+fn clip_frames(
+ args: &mut Vec,
+ prepare: &mut Prepare,
+ filters: &mut Vec,
+ next_input: &mut usize,
+ project: &Project,
+ resolve: &impl Resolve,
+ clip: &Clip,
+ drawn: &crate::compose::Drawn,
+ source: Source,
+ still_at: Option,
+ tag: &str,
+) -> Result {
+ let fps = project.settings.fps;
+ let frame = project.frame();
+ let length = match still_at {
+ Some(_) => frame,
+ None => drawn.to - drawn.from,
+ };
+ let len = fmt(length);
+ let label = format!("f{tag}");
+ let mut take = || {
+ let i = *next_input;
+ *next_input += 1;
+ i
+ };
+ let looped = |args: &mut Vec, path: &str| {
+ args.extend(strings(&[
+ "-loop",
+ "1",
+ "-framerate",
+ &fps.to_string(),
+ "-t",
+ &len,
+ "-i",
+ path,
+ ]));
+ };
+ let media = match source {
+ Source::Generated(generator) => {
+ let picture = resolve
+ .generated(clip, generator)
+ .ok_or_else(|| format!("{} could not be drawn", generator.name()))?;
+ looped(args, &picture.path.to_string_lossy());
+ let i = take();
+ filters.push(format!("[{i}:v]fps={fps},setpts=PTS-STARTPTS[{label}]"));
+ return Ok(Frames {
+ label,
+ rendered: Some((picture.width, picture.height)),
+ shared: None,
+ });
+ }
+ Source::Media(media) => media,
+ };
+ let path = resolve
+ .media(media)
+ .ok_or_else(|| format!("{} is offline", media.name))?
+ .to_string_lossy()
+ .to_string();
+ if media.kind == MediaKind::Image {
+ looped(args, &path);
+ let i = take();
+ filters.push(format!("[{i}:v]fps={fps},setpts=PTS-STARTPTS[{label}]"));
+ return Ok(Frames {
+ label,
+ rendered: None,
+ shared: None,
+ });
+ }
+ let last = (media.duration - frame).max(0);
+ // One frame (a still, or a held frame): the last frame at or before
+ // that moment, read backwards from just after it, so it is found even
+ // where the file's sound runs on past its picture.
+ if still_at.is_some() || clip.frozen {
+ let at = match still_at {
+ Some(t) => clip.source_at(t),
+ None => clip.source_in,
+ }
+ .clamp(0, last);
+ let from = (at - SECOND).max(0);
+ args.extend(strings(&[
+ "-ss",
+ &fmt(from),
+ "-t",
+ &fmt(at - from + frame),
+ "-i",
+ &path,
+ ]));
+ let i = take();
+ let hold = match still_at {
+ Some(_) => String::new(),
+ None => format!(",tpad=stop_mode=clone:stop_duration={len},trim=duration={len},setpts=PTS-STARTPTS"),
+ };
+ filters.push(format!(
+ "[{i}:v]reverse,trim=end_frame=1,setpts=PTS-STARTPTS,fps={fps}{hold}[{label}]"
+ ));
+ return Ok(Frames {
+ label,
+ rendered: None,
+ shared: None,
+ });
+ }
+ let speed = clip.speed;
+ let source_of = |t: Time| (t as f64 * speed).round() as Time;
+ let (head, tail) = (drawn.head(), drawn.tail());
+ // The source the drawn stretch wants, and the part of it the media has.
+ let (want_lo, want_hi) = if clip.reverse {
+ (clip.source_in - source_of(tail), clip.source_out() + source_of(head))
+ } else {
+ (clip.source_in - source_of(head), clip.source_out() + source_of(tail))
+ };
+ let lo = want_lo.clamp(0, last);
+ let hi = want_hi.clamp(lo + frame, media.duration.max(lo + frame));
+ // What the media lacks is held still, before and after.
+ let timeline = |t: Time| secs(t.max(0)) / speed;
+ let (early, late) = (timeline(lo - want_lo), timeline(want_hi - hi));
+ let (before, after) = if clip.reverse { (late, early) } else { (early, late) };
+ let retime = if speed == 1.0 {
+ "setpts=PTS-STARTPTS".to_string()
+ } else {
+ format!("setpts=(PTS-STARTPTS)/{speed:.6}")
+ };
+ // Padding past the end too, so a file a little shorter than it says
+ // still fills the clip; the trim makes the length exact.
+ let pad = format!(
+ "tpad=start_mode=clone:start_duration={before:.6}:stop_mode=clone:stop_duration={:.6}",
+ after + 0.5
+ );
+ let finish = format!("{retime},fps={fps},{pad},trim=duration={len},setpts=PTS-STARTPTS[{label}]");
+ if !clip.reverse {
+ args.extend(strings(&["-ss", &fmt(lo), "-t", &fmt(hi - lo), "-i", &path]));
+ let i = take();
+ filters.push(format!("[{i}:v]{finish}"));
+ return Ok(Frames {
+ label,
+ rendered: None,
+ shared: Some((i, lo)),
+ });
+ }
+ // Backwards: the latest piece first, each reversed on its own into a
+ // file before the export (reversing holds a piece in memory, and every
+ // input of one ffmpeg decodes at once), padded to its exact length so
+ // the pieces stay in step with the sound. Then joined.
+ let see_through = matches!(
+ &media.recipe,
+ Some(crate::model::Recipe::Symbol(_))
+ | Some(crate::model::Recipe::Scene(crate::model::Scene {
+ transparent: true,
+ ..
+ }))
+ );
+ let mut pieces = Vec::new();
+ let mut top = hi;
+ while top > lo {
+ let bottom = (top - REVERSE_PIECE).max(lo);
+ let length = fmt(top - bottom);
+ let file = prepare.dir.join(format!("{tag}-{}.mov", pieces.len()));
+ let mut command = strings(&["-hide_banner", "-nostdin", "-y", "-loglevel", "error"]);
+ command.extend(strings(&[
+ "-ss",
+ &fmt(bottom),
+ "-t",
+ &length,
+ "-i",
+ &path,
+ "-an",
+ "-vf",
+ ]));
+ command.push(format!(
+ "setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration={length},trim=duration={length},reverse"
+ ));
+ command.extend(if see_through {
+ strings(&["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le"])
+ } else {
+ strings(&[
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ "-crf",
+ "10",
+ "-g",
+ "1",
+ "-pix_fmt",
+ "yuv420p",
+ ])
+ });
+ command.push(file.to_string_lossy().to_string());
+ prepare.commands.push(command);
+ pieces.push(file);
+ top = bottom;
+ }
+ // Joined into one file (copied, not encoded again), so the export
+ // opens one input however long the clip.
+ let list = prepare.dir.join(format!("{tag}-pieces.txt"));
+ let joined = prepare.dir.join(format!("{tag}-reversed.mov"));
+ prepare.files.push((
+ list.clone(),
+ pieces
+ .iter()
+ .map(|f| format!("file '{}'\n", f.to_string_lossy().replace('\'', "'\\''")))
+ .collect(),
+ ));
+ let mut join = strings(&["-hide_banner", "-nostdin", "-y", "-loglevel", "error"]);
+ join.extend(strings(&[
+ "-f",
+ "concat",
+ "-safe",
+ "0",
+ "-i",
+ &list.to_string_lossy(),
+ "-c",
+ "copy",
+ ]));
+ join.push(joined.to_string_lossy().to_string());
+ prepare.commands.push(join);
+ prepare.used = true;
+ args.extend(strings(&["-i", &joined.to_string_lossy()]));
+ let i = take();
+ filters.push(format!("[{i}:v]setpts=PTS-STARTPTS,{finish}"));
+ Ok(Frames {
+ label,
+ rendered: None,
+ shared: None,
+ })
+}
+
+/// The sound of a clip, at its speed and direction, delayed to its start.
+/// `shared` is an input already open on its file (and the source time it
+/// starts at); without one, the clip gets its own. Returns the stream's label.
+#[allow(clippy::too_many_arguments)]
+fn clip_sound(
+ filters: &mut Vec,
+ args: &mut Vec,
+ next_input: &mut usize,
+ resolve: &impl Resolve,
+ clip: &Clip,
+ media: &Media,
+ shared: Option<(usize, Time)>,
+ sound: crate::compose::Sound,
+ tag: &str,
+) -> Result {
+ // A retimed clip plays its own stretch; one as recorded may run on past
+ // its edges for a crossfade (`sound`).
+ let (source, length) = if clip.is_retimed() {
+ (clip.source_in, clip.span())
+ } else {
+ (sound.source, sound.to - sound.from)
+ };
+ let (i, trim) = match shared.filter(|_| !clip.reverse) {
+ Some((i, from)) => (
+ i,
+ format!("atrim=start={}:duration={},", fmt((source - from).max(0)), fmt(length)),
+ ),
+ None => {
+ let path = resolve
+ .media(media)
+ .ok_or_else(|| format!("{} is offline", media.name))?;
+ args.extend(strings(&[
+ "-ss",
+ &fmt(source),
+ "-t",
+ &fmt(length),
+ "-i",
+ &path.to_string_lossy(),
+ ]));
+ let i = *next_input;
+ *next_input += 1;
+ (i, String::new())
+ }
+ };
+ let mut chain = vec![format!("{trim}aresample=48000"), "asetpts=PTS-STARTPTS".to_string()];
+ if clip.reverse {
+ chain.push("areverse".into());
+ }
+ // atempo keeps the pitch; each one stretches by at most a factor of two.
+ let mut rest = clip.speed;
+ while rest < 0.5 - 1e-9 {
+ chain.push("atempo=0.5".into());
+ rest /= 0.5;
+ }
+ while rest > 2.0 + 1e-9 {
+ chain.push("atempo=2.0".into());
+ rest /= 2.0;
+ }
+ if (rest - 1.0).abs() > 1e-9 {
+ chain.push(format!("atempo={rest:.6}"));
+ }
+ chain.push(format!("volume={:.3}", clip.volume));
+ for (fade, direction) in [(sound.fade_in, "in"), (sound.fade_out, "out")] {
+ if let Some((a, z)) = fade {
+ chain.push(format!(
+ "afade=t={direction}:st={}:d={}:curve=qsin",
+ fmt((a - sound.from).max(0)),
+ fmt((z - a).max(1))
+ ));
+ }
+ }
+ chain.push(format!("adelay={}:all=1", sound.from.max(0) / 1000));
+ filters.push(format!("[{i}:a]{}[a{tag}]", chain.join(",")));
+ Ok(format!("[a{tag}]"))
}
/// Reads one line of `-progress` output; returns the encoded position.
@@ -629,10 +1126,13 @@ mod tests {
let graph = graph(&plan);
// Resizing every frame rounds to whole pixels, and a slow zoom jitters.
assert!(!graph.contains("trunc("), "no per-frame resize: {graph}");
- assert!(graph.contains("scale=1920:1080,pad=1920:1080:"), "{graph}");
+ assert!(
+ graph.contains("scale=1920:1080,pad=1924:1084:"),
+ "a clear border: {graph}"
+ );
assert!(graph.contains("perspective=sense=destination:interpolation=cubic:eval=frame"));
assert!(graph.contains("(in/30)"), "the zoom follows the frame count");
- assert!(graph.contains("overlay=x='-0':y='-0'"), "{graph}");
+ assert!(graph.contains("overlay=x='-2':y='-2'"), "{graph}");
}
#[test]
@@ -651,7 +1151,10 @@ mod tests {
);
assert!(!graph.contains("amix"), "a still has no sound");
let seek = plan.args.iter().position(|a| a == "-ss").unwrap();
- assert_eq!(plan.args[seek + 1], "0.250000", "a quarter second into the second clip");
+ // Read backwards from just after a quarter second into the second clip.
+ assert_eq!(plan.args[seek + 1], "0.000000");
+ assert_eq!(plan.args[seek + 3], "0.283333", "up to a frame past 0.25s");
+ assert!(graph.contains("reverse,trim=end_frame=1"));
assert!(
graph.contains("colorchannelmixer=aa=0.5000") && !graph.contains("fade="),
"the fade is half done: {graph}"
diff --git a/crates/splice-core/src/lib.rs b/crates/splice-core/src/lib.rs
index d5d2e16..c3fd6d2 100644
--- a/crates/splice-core/src/lib.rs
+++ b/crates/splice-core/src/lib.rs
@@ -4,6 +4,7 @@
pub mod bible;
pub mod brief;
+pub mod compose;
pub mod export;
pub mod history;
pub mod look;
@@ -14,3 +15,4 @@ pub mod store;
pub mod story;
pub mod symbol;
pub mod time;
+pub mod transition;
diff --git a/crates/splice-core/src/look.rs b/crates/splice-core/src/look.rs
index 094d781..c9684a7 100644
--- a/crates/splice-core/src/look.rs
+++ b/crates/splice-core/src/look.rs
@@ -31,6 +31,13 @@ pub struct Look {
pub enter: Motion,
pub exit: Motion,
pub drift: Drift,
+ /// Colour: a look, and exposure, contrast, saturation, warmth and tint.
+ #[serde(skip_serializing_if = "Grade::is_none")]
+ pub grade: Grade,
+ /// Keyframes: place, size and opacity set at moments in the clip, eased
+ /// between. A property with keyframes follows them instead of its value above.
+ #[serde(skip_serializing_if = "Keys::is_empty")]
+ pub keys: Keys,
}
impl Default for Look {
@@ -46,10 +53,500 @@ impl Default for Look {
enter: Motion::default(),
exit: Motion::default(),
drift: Drift::None,
+ grade: Grade::default(),
+ keys: Keys::default(),
}
}
}
+/// A ready-made look, under the grade's own adjustments.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Preset {
+ #[default]
+ None,
+ Warm,
+ Cool,
+ Vivid,
+ Muted,
+ Faded,
+ Film,
+ TealOrange,
+ Mono,
+ Noir,
+}
+
+impl Preset {
+ pub const ALL: [Preset; 10] = [
+ Preset::None,
+ Preset::Warm,
+ Preset::Cool,
+ Preset::Vivid,
+ Preset::Muted,
+ Preset::Faded,
+ Preset::Film,
+ Preset::TealOrange,
+ Preset::Mono,
+ Preset::Noir,
+ ];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Preset::None => "none",
+ Preset::Warm => "warm",
+ Preset::Cool => "cool",
+ Preset::Vivid => "vivid",
+ Preset::Muted => "muted",
+ Preset::Faded => "faded",
+ Preset::Film => "film",
+ Preset::TealOrange => "teal-orange",
+ Preset::Mono => "mono",
+ Preset::Noir => "noir",
+ }
+ }
+
+ /// As the inspector says it.
+ pub fn label(self) -> &'static str {
+ match self {
+ Preset::None => "None",
+ Preset::Warm => "Warm",
+ Preset::Cool => "Cool",
+ Preset::Vivid => "Vivid",
+ Preset::Muted => "Muted",
+ Preset::Faded => "Faded",
+ Preset::Film => "Film",
+ Preset::TealOrange => "Teal & orange",
+ Preset::Mono => "Black & white",
+ Preset::Noir => "Noir",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option {
+ let name = name.trim().to_lowercase().replace([' ', '_'], "-").replace('&', "");
+ let name = match name.as_str() {
+ "teal--orange" | "teal-and-orange" | "orange-teal" => "teal-orange",
+ "black-white" | "black--white" | "black-and-white" | "bw" | "b-w" | "grey" | "gray" => "mono",
+ other => other,
+ }
+ .to_string();
+ Preset::ALL.into_iter().find(|p| p.name() == name)
+ }
+
+ /// Its ffmpeg filters.
+ fn ffmpeg(self) -> Option<&'static str> {
+ Some(match self {
+ Preset::None => return None,
+ Preset::Warm => "colorchannelmixer=rr=1.08:gg=1.01:bb=0.88,eq=saturation=1.06",
+ Preset::Cool => "colorchannelmixer=rr=0.9:gg=0.99:bb=1.1,eq=saturation=0.96",
+ Preset::Vivid => "eq=contrast=1.08:saturation=1.38",
+ Preset::Muted => "eq=contrast=0.96:saturation=0.66",
+ Preset::Faded => "curves=all='0/0.09 0.5/0.52 1/0.9',eq=saturation=0.78",
+ Preset::Film => {
+ "curves=preset=medium_contrast,colorbalance=rs=-0.04:bs=0.05:rh=0.05:bh=-0.05,eq=saturation=0.9"
+ }
+ Preset::TealOrange => "colorbalance=rs=-0.12:gs=0.02:bs=0.12:rh=0.1:gh=0.02:bh=-0.1,eq=saturation=1.1",
+ Preset::Mono => "hue=s=0",
+ Preset::Noir => "hue=s=0,curves=preset=strong_contrast",
+ })
+ }
+}
+
+/// How a clip's colour is changed. Every adjustment is 0 when untouched.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Grade {
+ pub preset: Preset,
+ /// Stops brighter (up to 1) or darker (down to −1).
+ pub exposure: f32,
+ /// −1 to 1: flatter to punchier.
+ pub contrast: f32,
+ /// −1 (grey) to 1.
+ pub saturation: f32,
+ /// −1 (cooler, bluer) to 1 (warmer, more orange).
+ pub temperature: f32,
+ /// −1 (greener) to 1 (more magenta).
+ pub tint: f32,
+}
+
+impl Grade {
+ pub fn is_none(&self) -> bool {
+ *self == Grade::default()
+ }
+
+ /// Every adjustment in range.
+ pub fn clamped(self) -> Grade {
+ let unit = |v: f32| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 };
+ Grade {
+ preset: self.preset,
+ exposure: unit(self.exposure),
+ contrast: unit(self.contrast),
+ saturation: unit(self.saturation),
+ temperature: unit(self.temperature),
+ tint: unit(self.tint),
+ }
+ }
+
+ /// Its ffmpeg filters: the look first, then the adjustments over it.
+ pub fn ffmpeg(&self) -> Option {
+ let g = self.clamped();
+ let mut filters: Vec = g.preset.ffmpeg().map(String::from).into_iter().collect();
+ let near = |v: f32| v.abs() < 0.005;
+ // Exposure as a gain, warmth and tint as a lean of the channels.
+ if !(near(g.exposure) && near(g.temperature) && near(g.tint)) {
+ let k = 2f32.powf(g.exposure);
+ let (r, gr, b) = (
+ k * (1.0 + 0.14 * g.temperature),
+ k * (1.0 - 0.1 * g.tint),
+ k * (1.0 - 0.14 * g.temperature),
+ );
+ filters.push(format!(
+ "colorchannelmixer=rr={:.4}:gg={:.4}:bb={:.4}",
+ r.clamp(0.0, 2.0),
+ gr.clamp(0.0, 2.0),
+ b.clamp(0.0, 2.0)
+ ));
+ }
+ if !(near(g.contrast) && near(g.saturation)) {
+ // Contrast eases off below the middle, so −1 is flat, not grey.
+ let contrast = if g.contrast >= 0.0 {
+ 1.0 + 0.8 * g.contrast
+ } else {
+ 1.0 + 0.6 * g.contrast
+ };
+ filters.push(format!(
+ "eq=contrast={contrast:.4}:saturation={:.4}",
+ (1.0 + g.saturation).clamp(0.0, 3.0)
+ ));
+ }
+ (!filters.is_empty()).then(|| filters.join(","))
+ }
+
+ /// What it does, in a few words: "Film · warmer · +0.5 stop".
+ pub fn summary(&self) -> String {
+ let g = self.clamped();
+ let mut said = Vec::new();
+ if g.preset != Preset::None {
+ said.push(g.preset.label().to_string());
+ }
+ if g.exposure.abs() >= 0.05 {
+ said.push(format!("{:+.1} stop", g.exposure));
+ }
+ let lean = |v: f32, up: &str, down: &str| {
+ if v >= 0.05 {
+ Some(up.to_string())
+ } else if v <= -0.05 {
+ Some(down.to_string())
+ } else {
+ None
+ }
+ };
+ said.extend(lean(g.contrast, "more contrast", "less contrast"));
+ said.extend(lean(g.saturation, "richer", "paler"));
+ said.extend(lean(g.temperature, "warmer", "cooler"));
+ said.extend(lean(g.tint, "magenta", "green"));
+ if said.is_empty() {
+ "Off".into()
+ } else {
+ said.join(" · ")
+ }
+ }
+}
+
+/// How a keyframed value leaves a keyframe for the next one.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Ease {
+ Linear,
+ /// Starts slowly.
+ In,
+ /// Arrives slowly.
+ Out,
+ /// Starts and arrives slowly.
+ #[default]
+ InOut,
+ /// Stays put, then jumps at the next keyframe.
+ Hold,
+}
+
+impl Ease {
+ pub const ALL: [Ease; 5] = [Ease::Linear, Ease::In, Ease::Out, Ease::InOut, Ease::Hold];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Ease::Linear => "linear",
+ Ease::In => "in",
+ Ease::Out => "out",
+ Ease::InOut => "in-out",
+ Ease::Hold => "hold",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option {
+ let name = name.trim().to_lowercase();
+ let name = match name.as_str() {
+ "ease-in" | "ease in" => "in",
+ "ease-out" | "ease out" => "out",
+ "ease" | "smooth" | "ease-in-out" | "inout" => "in-out",
+ "step" | "none" => "hold",
+ other => other,
+ };
+ Ease::ALL.into_iter().find(|e| e.name() == name)
+ }
+
+ /// Progress `p` (0 to 1, already clipped) eased.
+ fn expr(self, p: Expr) -> Expr {
+ match self {
+ Ease::Linear => p,
+ Ease::In => pow(p, 3.0),
+ Ease::Out => sub(C(1.0), pow(sub(C(1.0), p), 3.0)),
+ // Smoothstep: 3p² − 2p³.
+ Ease::InOut => mul(mul(p.clone(), p.clone()), sub(C(3.0), mul(C(2.0), p))),
+ Ease::Hold => C(0.0),
+ }
+ }
+}
+
+/// What keyframes can animate.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Prop {
+ X,
+ Y,
+ Scale,
+ Opacity,
+}
+
+impl Prop {
+ pub const ALL: [Prop; 4] = [Prop::X, Prop::Y, Prop::Scale, Prop::Opacity];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Prop::X => "x",
+ Prop::Y => "y",
+ Prop::Scale => "scale",
+ Prop::Opacity => "opacity",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option {
+ match name.trim().to_lowercase().as_str() {
+ "x" | "across" => Some(Prop::X),
+ "y" | "down" => Some(Prop::Y),
+ "scale" | "size" => Some(Prop::Scale),
+ "opacity" => Some(Prop::Opacity),
+ _ => None,
+ }
+ }
+
+ /// The look's own value for it.
+ pub fn of(self, look: &Look) -> f32 {
+ match self {
+ Prop::X => look.x,
+ Prop::Y => look.y,
+ Prop::Scale => look.scale,
+ Prop::Opacity => look.opacity,
+ }
+ }
+
+ /// Its value kept in range.
+ pub fn clamp(self, v: f32) -> f32 {
+ let v = if v.is_finite() { v } else { 0.0 };
+ match self {
+ Prop::X | Prop::Y => v.clamp(-4.0, 4.0),
+ Prop::Scale => v.clamp(0.01, 20.0),
+ Prop::Opacity => v.clamp(0.0, 1.0),
+ }
+ }
+}
+
+/// A value at a moment of the clip (clip time, from its start).
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Keyframe {
+ pub at: Time,
+ pub value: f32,
+ #[serde(default)]
+ pub ease: Ease,
+}
+
+/// Keyframes per property, each list in time order.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Keys {
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub x: Vec,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub y: Vec,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub scale: Vec,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub opacity: Vec,
+}
+
+/// Keyframes closer than this are the same one.
+pub const KEY_NEAR: Time = SECOND / 100;
+
+impl Keys {
+ pub fn is_empty(&self) -> bool {
+ Prop::ALL.iter().all(|p| self.of(*p).is_empty())
+ }
+
+ pub fn of(&self, prop: Prop) -> &[Keyframe] {
+ match prop {
+ Prop::X => &self.x,
+ Prop::Y => &self.y,
+ Prop::Scale => &self.scale,
+ Prop::Opacity => &self.opacity,
+ }
+ }
+
+ pub fn of_mut(&mut self, prop: Prop) -> &mut Vec {
+ match prop {
+ Prop::X => &mut self.x,
+ Prop::Y => &mut self.y,
+ Prop::Scale => &mut self.scale,
+ Prop::Opacity => &mut self.opacity,
+ }
+ }
+
+ /// Sets `prop` to `value` at `at`, replacing a keyframe there. A new
+ /// keyframe takes the ease of the one before it.
+ pub fn set(&mut self, prop: Prop, at: Time, value: f32, ease: Option) {
+ let value = prop.clamp(value);
+ let keys = self.of_mut(prop);
+ if let Some(k) = keys.iter_mut().find(|k| (k.at - at).abs() <= KEY_NEAR) {
+ k.value = value;
+ if let Some(e) = ease {
+ k.ease = e;
+ }
+ return;
+ }
+ let i = keys.partition_point(|k| k.at < at);
+ let ease = ease.unwrap_or_else(|| i.checked_sub(1).map_or(Ease::default(), |b| keys[b].ease));
+ keys.insert(i, Keyframe { at, value, ease });
+ }
+
+ /// Takes away the keyframe at `at`; whether there was one.
+ pub fn remove(&mut self, prop: Prop, at: Time) -> bool {
+ let keys = self.of_mut(prop);
+ let before = keys.len();
+ keys.retain(|k| (k.at - at).abs() > KEY_NEAR);
+ keys.len() != before
+ }
+
+ /// Every moment with a keyframe, in order.
+ pub fn moments(&self) -> Vec {
+ let mut at: Vec = Prop::ALL
+ .iter()
+ .flat_map(|p| self.of(*p).iter().map(|k| k.at))
+ .collect();
+ at.sort_unstable();
+ at.dedup_by(|a, b| (*a - *b).abs() <= KEY_NEAR);
+ at
+ }
+
+ /// Whether any property has a keyframe at `at`.
+ pub fn has_at(&self, at: Time) -> bool {
+ Prop::ALL
+ .iter()
+ .any(|p| self.of(*p).iter().any(|k| (k.at - at).abs() <= KEY_NEAR))
+ }
+
+ /// The value `prop` follows at clip time `t`, or None without keyframes.
+ pub fn value(&self, prop: Prop, t: Time) -> Option {
+ let keys = self.of(prop);
+ (!keys.is_empty()).then(|| keyed(keys, C(0.0)).eval(secs(t)) as f32)
+ }
+
+ /// Moves every keyframe by `dt`. Those that leave the clip are kept, so
+ /// trimming back out finds them again.
+ pub fn shift(&mut self, dt: Time) {
+ for prop in Prop::ALL {
+ for k in self.of_mut(prop) {
+ k.at += dt;
+ }
+ }
+ }
+
+ /// Stretches keyframe times by `k` (a clip played at another speed).
+ pub fn scale_time(&mut self, k: f64) {
+ for prop in Prop::ALL {
+ for key in self.of_mut(prop) {
+ key.at = (key.at as f64 * k).round() as Time;
+ }
+ }
+ }
+
+ /// The keyframes of the part of a clip from `from` to `to` (clip time),
+ /// rebased to start there. Each animated property keeps its value at both
+ /// ends, so the part moves exactly as it did.
+ pub fn part(&self, from: Time, to: Time) -> Keys {
+ let mut out = Keys::default();
+ for prop in Prop::ALL {
+ let keys = self.of(prop);
+ if keys.is_empty() {
+ continue;
+ }
+ let at = |t: Time| keyed(keys, C(0.0)).eval(secs(t)) as f32;
+ let mut part: Vec = keys
+ .iter()
+ .filter(|k| k.at > from + KEY_NEAR && k.at < to - KEY_NEAR)
+ .map(|k| Keyframe { at: k.at - from, ..*k })
+ .collect();
+ // Each end takes the ease of the stretch it is in.
+ let ease_at = |t: Time| {
+ keys.iter()
+ .rev()
+ .find(|k| k.at <= t + KEY_NEAR)
+ .map_or(keys[0].ease, |k| k.ease)
+ };
+ part.insert(
+ 0,
+ Keyframe {
+ at: 0,
+ value: at(from),
+ ease: ease_at(from),
+ },
+ );
+ part.push(Keyframe {
+ at: to - from,
+ value: at(to),
+ ease: ease_at(to),
+ });
+ *out.of_mut(prop) = part;
+ }
+ out
+ }
+}
+
+/// The value keyframes give over clip time `t` (seconds), as an expression:
+/// the first value, plus each step to the next eased across its stretch.
+/// `t` is the expression for clip time; keyframes are in clip time.
+fn keyed(keys: &[Keyframe], t: Expr) -> Expr {
+ let t = if t == C(0.0) { Expr::T } else { t };
+ let Some(first) = keys.first() else {
+ return C(0.0);
+ };
+ let mut value = C(first.value as f64);
+ for pair in keys.windows(2) {
+ let (a, z) = (pair[0], pair[1]);
+ let step = (z.value - a.value) as f64;
+ if step == 0.0 {
+ continue;
+ }
+ let progress = if a.ease == Ease::Hold || z.at <= a.at {
+ // Jumps once the next keyframe is reached.
+ clip01(Expr::Div(b(add(sub(t.clone(), C(secs(z.at))), C(1e-4))), b(C(1e-4))))
+ } else {
+ a.ease.expr(clip01(Expr::Div(
+ b(sub(t.clone(), C(secs(a.at)))),
+ b(C(secs(z.at - a.at))),
+ )))
+ };
+ value = add(value, mul(C(step), progress));
+ }
+ value
+}
+
/// Green (or blue, or any colour) screen: pixels near `color` turn clear.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -367,19 +864,97 @@ impl Expr {
}
/// The clip's pose over its local time, as expressions.
+#[derive(Clone, Debug, PartialEq)]
pub struct PoseExpr {
pub x: Expr,
pub y: Expr,
pub scale: Expr,
+ pub opacity: Expr,
+}
+
+impl PoseExpr {
+ /// The pose `t` seconds into the clip.
+ pub fn at(&self, t: f64) -> Pose {
+ Pose {
+ x: self.x.eval(t),
+ y: self.y.eval(t),
+ scale: self.scale.eval(t).max(0.0),
+ opacity: self.opacity.eval(t).clamp(0.0, 1.0),
+ }
+ }
+
+ /// Whether where it is or how big changes over time.
+ pub fn moves(&self) -> bool {
+ self.x.constant().is_none() || self.y.constant().is_none() || self.scale.constant().is_none()
+ }
+}
+
+/// Builders for expressions, for other modules that animate clips.
+pub mod build {
+ use super::Expr;
+ pub use super::Expr::{C, T};
+
+ pub fn add(a: Expr, z: Expr) -> Expr {
+ super::add(a, z)
+ }
+ pub fn sub(a: Expr, z: Expr) -> Expr {
+ super::sub(a, z)
+ }
+ pub fn mul(a: Expr, z: Expr) -> Expr {
+ super::mul(a, z)
+ }
+ pub fn div(a: Expr, z: Expr) -> Expr {
+ match (&a, &z) {
+ (Expr::C(x), Expr::C(y)) if *y != 0.0 => Expr::C(x / y),
+ _ => Expr::Div(Box::new(a), Box::new(z)),
+ }
+ }
+ pub fn clip01(a: Expr) -> Expr {
+ super::clip01(a)
+ }
+ /// Starts and arrives slowly: 3p² − 2p³.
+ pub fn smooth(p: Expr) -> Expr {
+ super::Ease::InOut.expr(p)
+ }
}
impl Look {
+ /// The value `prop` has at clip time `t`: its keyframes', or its own.
+ pub fn value(&self, prop: Prop, t: Time) -> f32 {
+ self.keys.value(prop, t).unwrap_or_else(|| prop.of(self))
+ }
+
+ /// Sets `prop` at clip time `at`: a keyframe there when it is animated,
+ /// otherwise its value throughout.
+ pub fn set_value(&mut self, prop: Prop, at: Time, v: f32) {
+ if !self.keys.of(prop).is_empty() {
+ self.keys.set(prop, at, v, None);
+ return;
+ }
+ let v = prop.clamp(v);
+ match prop {
+ Prop::X => self.x = v,
+ Prop::Y => self.y = v,
+ Prop::Scale => self.scale = v,
+ Prop::Opacity => self.opacity = v,
+ }
+ }
+
+ /// `prop` over clip time, as an expression.
+ fn prop_expr(&self, prop: Prop) -> Expr {
+ match self.keys.of(prop) {
+ [] => C(prop.of(self) as f64),
+ keys => keyed(keys, Expr::T),
+ }
+ }
+
/// How the clip moves over a clip of `duration`.
pub fn pose_expr(&self, duration: Time) -> PoseExpr {
let d = secs(duration).max(1e-3);
- let mut x = C(self.x as f64);
- let mut y = C(self.y as f64);
- let mut scale = C(self.scale as f64);
+ let mut x = self.prop_expr(Prop::X);
+ let mut y = self.prop_expr(Prop::Y);
+ let mut scale = self.prop_expr(Prop::Scale);
+ let mut opacity = self.prop_expr(Prop::Opacity);
// Progress into the clip (enter) and out of it (exit), 0 to 1.
let ends = [
(
@@ -395,6 +970,10 @@ impl Look {
),
];
for (motion, p) in ends {
+ // Every motion fades as it moves.
+ if motion.kind != MotionKind::None {
+ opacity = mul(opacity, p.clone());
+ }
let eased = sub(C(1.0), pow(sub(C(1.0), p.clone()), 3.0));
let rest = sub(C(1.0), eased.clone());
match motion.kind {
@@ -421,42 +1000,32 @@ 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))),
}
- PoseExpr { x, y, scale }
+ PoseExpr { x, y, scale, opacity }
}
/// Where the clip is `local` into a clip of `duration`.
pub fn pose(&self, duration: Time, local: Time) -> Pose {
- let e = self.pose_expr(duration);
- let t = secs(local);
- let d = secs(duration);
- let fade = |m: Motion, p: f64| {
- if m.kind == MotionKind::None {
- 1.0
- } else {
- (p / secs(m.duration).max(1e-3)).clamp(0.0, 1.0)
- }
- };
- Pose {
- x: e.x.eval(t),
- y: e.y.eval(t),
- scale: e.scale.eval(t).max(0.0),
- opacity: self.opacity as f64 * fade(self.enter, t) * fade(self.exit, d - t),
- }
+ self.pose_expr(duration).at(secs(local))
}
/// Whether anything about the clip changes over time.
pub fn is_animated(&self) -> bool {
- self.enter.kind != MotionKind::None || self.exit.kind != MotionKind::None || self.drift != Drift::None
+ self.enter.kind != MotionKind::None
+ || self.exit.kind != MotionKind::None
+ || self.drift != Drift::None
+ || !self.keys.is_empty()
}
- /// Filters that change the clip's own pixels (key and mask), shared by
- /// the viewer's decoder and export. They leave an RGBA frame.
+ /// 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 {
let mut filters = Vec::new();
// 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());
}
+ // Colour before the key would change what counts as green: key first.
+ let graded = self.grade.ffmpeg();
if let Some(key) = self.key {
filters.push(format!(
"chromakey=0x{:06x}:{:.4}:{:.4}",
@@ -473,6 +1042,9 @@ impl Look {
}
}
}
+ if let Some(grade) = graded {
+ filters.push(grade);
+ }
if let Some(mask) = self.mask {
filters.push("format=rgba".into());
filters.push(format!(
diff --git a/crates/splice-core/src/model.rs b/crates/splice-core/src/model.rs
index d70204c..bd46b88 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 = 5;
+pub const SCHEMA: u32 = 6;
/// How long a still image runs when it is first placed.
pub const IMAGE_DURATION: Time = 5 * SECOND;
@@ -177,12 +177,40 @@ pub struct Clip {
/// together.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option,
+ /// How fast the source plays: 2 is twice as fast (and the clip half as long).
+ #[serde(default = "one64", skip_serializing_if = "is_one")]
+ pub speed: f64,
+ /// Plays its stretch of the source backwards.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub reverse: bool,
+ /// Holds one frame (at `source_in`) for as long as the clip lasts.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub frozen: bool,
+ /// The way in from the clip before it (or from what is beneath).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub transition: Option,
}
fn is_default(look: &Look) -> bool {
*look == Look::default()
}
+fn one64() -> f64 {
+ 1.0
+}
+
+fn is_one(v: &f64) -> bool {
+ *v == 1.0
+}
+
+fn is_false(v: &bool) -> bool {
+ !*v
+}
+
+/// The slowest and fastest a clip plays.
+pub const SLOWEST: f64 = 0.1;
+pub const FASTEST: f64 = 16.0;
+
/// Content Splice draws itself.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "kind")]
@@ -276,6 +304,25 @@ fn one() -> f32 {
}
impl Clip {
+ /// A plain clip of `media`: as it is, at full volume, playing forwards.
+ pub fn new(id: ClipId, media: MediaId, start: Time, source_in: Time, duration: Time) -> Clip {
+ Clip {
+ id,
+ media,
+ start,
+ source_in,
+ duration,
+ volume: 1.0,
+ look: Look::default(),
+ generator: None,
+ placeholder: None,
+ speed: 1.0,
+ reverse: false,
+ frozen: false,
+ transition: None,
+ }
+ }
+
pub fn end(&self) -> Time {
self.start + self.duration
}
@@ -284,9 +331,42 @@ impl Clip {
self.start <= t && t < self.end()
}
- /// The source time shown at timeline time `t`.
+ /// How much source passes per second of timeline: its speed, or 0 when frozen.
+ pub fn rate(&self) -> f64 {
+ if self.frozen { 0.0 } else { self.speed }
+ }
+
+ /// How much of the source the clip plays.
+ pub fn span(&self) -> Time {
+ Self::scaled(self.duration, self.rate())
+ }
+
+ /// Where the stretch of source it plays ends.
+ pub fn source_out(&self) -> Time {
+ self.source_in + self.span()
+ }
+
+ fn scaled(t: Time, k: f64) -> Time {
+ (t as f64 * k).round() as Time
+ }
+
+ /// The source time shown at timeline time `t`. Outside the clip (a
+ /// transition drawing it early or late) it carries on the same way.
pub fn source_at(&self, t: Time) -> Time {
- self.source_in + (t - self.start)
+ let local = Self::scaled(t - self.start, self.rate());
+ if self.frozen {
+ self.source_in
+ } else if self.reverse {
+ // The last frame first: just before where its stretch ends.
+ self.source_out() - 1 - local
+ } else {
+ self.source_in + local
+ }
+ }
+
+ /// Whether this clip changes how its source plays.
+ pub fn is_retimed(&self) -> bool {
+ self.speed != 1.0 || self.reverse || self.frozen
}
}
@@ -523,18 +603,22 @@ impl Project {
{
// A clip of the whole thing stays the whole thing, as far as
// the next clip allows.
- let whole = clip.source_in == 0 && (clip.duration - old).abs() <= frame;
+ let whole = clip.source_in == 0 && (clip.span() - old).abs() <= frame;
clip.source_in = clip.source_in.min((duration - frame).max(0));
+ if clip.frozen {
+ continue;
+ }
let room = starts
.iter()
.copied()
.filter(|s| *s > clip.start)
.min()
.map_or(Time::MAX, |s| s - clip.start);
+ let timeline = |source: Time| (source as f64 / clip.speed).floor() as Time;
clip.duration = if whole {
- duration.min(room)
+ timeline(duration).min(room)
} else {
- clip.duration.min(duration - clip.source_in)
+ clip.duration.min(timeline(duration - clip.source_in))
}
.max(frame);
}
@@ -612,17 +696,7 @@ impl Project {
return None;
}
let id = ClipId(self.alloc());
- let clip = Clip {
- id,
- media,
- start: start.max(0),
- source_in: 0,
- duration,
- volume: 1.0,
- look: Look::default(),
- generator: None,
- placeholder: None,
- };
+ let clip = Clip::new(id, media, start.max(0), 0, duration);
self.place(clip, track);
Some(id)
}
@@ -631,15 +705,8 @@ impl Project {
pub fn insert_generated(&mut self, generator: Generator, track: usize, start: Time, duration: Time) -> ClipId {
let id = ClipId(self.alloc());
let clip = Clip {
- id,
- media: MediaId(0),
- start: start.max(0),
- source_in: 0,
- duration: duration.max(self.frame()),
- volume: 1.0,
- look: Look::default(),
generator: Some(generator),
- placeholder: None,
+ ..Clip::new(id, MediaId(0), start.max(0), 0, duration.max(self.frame()))
};
self.place(clip, track);
id
@@ -704,20 +771,30 @@ impl Project {
let min_len = self.frame();
let track = &self.tracks[slot.track];
let clip = &track.clips[slot.index];
- let timed = self.media(clip.media).is_some_and(Media::is_timed);
+ let media_length = self.media(clip.media).filter(|m| m.is_timed()).map(|m| m.duration);
+ let timed = media_length.is_some() && !clip.frozen;
let previous_end = slot.index.checked_sub(1).map_or(0, |i| track.clips[i].end());
let mut lower = previous_end.max(0);
- if timed {
- lower = lower.max(clip.start - clip.source_in);
+ if let Some(length) = media_length.filter(|_| timed) {
+ let timeline = |source: Time| (source as f64 / clip.speed).floor() as Time;
+ lower = lower.max(if clip.reverse {
+ // The left edge shows where its stretch ends, which the media bounds.
+ clip.end() - timeline(length - clip.source_in)
+ } else {
+ clip.start - timeline(clip.source_in)
+ });
}
let t = t.clamp(lower, clip.end() - min_len);
let clip = &mut self.tracks[slot.track].clips[slot.index];
let delta = t - clip.start;
+ let out = clip.source_out();
clip.start = t;
clip.duration -= delta;
- if timed {
- clip.source_in += delta;
+ if timed && !clip.reverse {
+ clip.source_in = (out - clip.span()).max(0);
}
+ // Keyframes stay with the moments they were set on.
+ clip.look.keys.shift(-delta);
}
/// Moves a clip's right edge to `t`.
@@ -748,12 +825,27 @@ impl Project {
return;
}
let mut upper = next_start;
- if let Some(media) = self.media(clip.media).filter(|m| m.is_timed()) {
- upper = upper.min(clip.start + media.duration - clip.source_in);
+ let timed = self.media(clip.media).filter(|m| m.is_timed() && !clip.frozen);
+ if let Some(media) = timed {
+ let timeline = |source: Time| (source as f64 / clip.speed).floor() as Time;
+ upper = upper.min(
+ clip.start
+ + if clip.reverse {
+ // The right edge shows where its stretch starts, which can reach 0.
+ timeline(clip.source_out())
+ } else {
+ timeline(media.duration - clip.source_in)
+ },
+ );
}
+ let timed = timed.is_some();
let t = t.clamp(clip.start + min_len, upper.max(clip.start + min_len));
let clip = &mut self.tracks[slot.track].clips[slot.index];
+ let out = clip.source_out();
clip.duration = t - clip.start;
+ if timed && clip.reverse {
+ clip.source_in = (out - clip.span()).max(0);
+ }
}
/// Cuts a clip in two at `t`; returns the right half.
@@ -764,24 +856,34 @@ impl Project {
if t < clip.start + min_len || t > clip.end() - min_len {
return None;
}
- let timed = self.media(clip.media).is_some_and(Media::is_timed);
+ let timed = self.media(clip.media).is_some_and(Media::is_timed) && !clip.frozen;
let new_id = ClipId(self.alloc());
let clip = &mut self.tracks[slot.track].clips[slot.index];
let left = t - clip.start;
+ let whole = clip.duration;
let mut right = Clip {
id: new_id,
- media: clip.media,
start: t,
- source_in: if timed { clip.source_in + left } else { 0 },
- duration: clip.duration - left,
- volume: clip.volume,
- look: clip.look.clone(),
- generator: clip.generator.clone(),
- placeholder: clip.placeholder.clone(),
+ duration: whole - left,
+ // A cut in the middle of a clip has no transition.
+ transition: None,
+ ..clip.clone()
};
- // The animation in stays with the left half, the one out with the right.
+ if timed {
+ let source = |t: Time| (t as f64 * clip.speed).round() as Time;
+ if clip.reverse {
+ // Backwards, the left half plays the later part of the source.
+ clip.source_in += source(whole - left);
+ } else {
+ right.source_in = clip.source_in + source(left);
+ }
+ }
+ // The animation in stays with the left half, the one out with the right;
+ // keyframes go with the half they are in, each half keeping its ends.
right.look.enter.kind = MotionKind::None;
clip.look.exit.kind = MotionKind::None;
+ right.look.keys = clip.look.keys.part(left, whole);
+ clip.look.keys = clip.look.keys.part(0, left);
clip.duration = left;
self.tracks[slot.track].clips.insert(slot.index + 1, right);
Some(new_id)
@@ -852,14 +954,143 @@ impl Project {
copies
}
+ /// Moves every clip on `track` that starts at or after `from` by `dt`.
+ fn ripple(&mut self, track: usize, from: Time, dt: Time, except: ClipId) {
+ if dt == 0 {
+ return;
+ }
+ for clip in &mut self.tracks[track].clips {
+ if clip.id != except && clip.start >= from {
+ clip.start += dt;
+ }
+ }
+ self.tracks[track].clips.sort_by_key(|c| c.start);
+ }
+
+ /// Plays clips at `speed` (2 is twice as fast), keeping what of the
+ /// source each plays: a clip sped up gets shorter and one slowed down
+ /// longer, and what follows on its track moves up or along to match.
+ pub fn set_speed(&mut self, ids: &[ClipId], speed: f64) {
+ let speed = if speed.is_finite() {
+ speed.clamp(SLOWEST, FASTEST)
+ } else {
+ 1.0
+ };
+ let frame = self.frame();
+ for &id in ids {
+ let Some(slot) = self.slot(id) else { continue };
+ let clip = &self.tracks[slot.track].clips[slot.index];
+ let timed = self.media(clip.media).is_some_and(|m| m.is_timed());
+ if clip.generator.is_some() || !timed || clip.frozen || clip.speed == speed {
+ continue;
+ }
+ let (end, old) = (clip.end(), clip.speed);
+ let span = clip.span();
+ // On whole frames, so what follows stays on the frame grid.
+ let fps = self.settings.fps;
+ let length = crate::time::floor_to_frame((span as f64 / speed).round() as Time + frame / 2, fps);
+ if length < frame {
+ // Too short to play that fast.
+ continue;
+ }
+ let clip = &mut self.tracks[slot.track].clips[slot.index];
+ let out = clip.source_out();
+ clip.speed = speed;
+ clip.duration = length;
+ if clip.reverse {
+ clip.source_in = (out - clip.span()).max(0);
+ }
+ // Keyframes and the way in keep their place in what is shown.
+ clip.look.keys.scale_time(old / speed);
+ if let Some(t) = &mut clip.transition {
+ t.duration = t.duration.min(clip.duration);
+ }
+ let dt = clip.end() - end;
+ self.ripple(slot.track, end, dt, id);
+ }
+ }
+
+ /// Plays clips backwards (or forwards again), over the same stretch of source.
+ pub fn set_reverse(&mut self, ids: &[ClipId], reverse: bool) {
+ for &id in ids {
+ let timed = self
+ .clip(id)
+ .filter(|c| c.generator.is_none() && !c.frozen)
+ .and_then(|c| self.media(c.media))
+ .is_some_and(|m| m.kind == MediaKind::Video || m.kind == MediaKind::Audio);
+ if let Some(clip) = self.clip_mut(id).filter(|_| timed) {
+ clip.reverse = reverse;
+ }
+ }
+ }
+
+ /// Holds the frame of clip `id` at timeline time `t` for `hold`: the clip
+ /// is cut there, the held frame goes between the halves, and what follows
+ /// on the track moves along. Returns the held clip.
+ pub fn freeze_frame(&mut self, id: ClipId, t: Time, hold: Time) -> Option {
+ let frame = self.frame();
+ let clip = self.clip(id)?.clone();
+ let media = self.media(clip.media)?;
+ if clip.generator.is_some() || media.kind != MediaKind::Video || clip.frozen || !clip.contains(t) {
+ return None;
+ }
+ let hold = hold.max(frame);
+ // The frame shown there, kept inside the media.
+ let at =
+ crate::time::floor_to_frame(clip.source_at(t), self.settings.fps).clamp(0, (media.duration - frame).max(0));
+ let slot = self.slot(id)?;
+ let mut t = crate::time::floor_to_frame(t, self.settings.fps).max(clip.start);
+ // Cut there; too near an edge to cut, the frame goes before or after it.
+ if t > clip.start && self.split(id, t).is_none() {
+ t = if t - clip.start < clip.end() - t {
+ clip.start
+ } else {
+ clip.end()
+ };
+ }
+ // What follows on the track (the right half too) moves along.
+ self.ripple(slot.track, t, hold, ClipId(u64::MAX));
+ let held = ClipId(self.alloc());
+ let mut still = Clip {
+ frozen: true,
+ volume: 0.0,
+ ..Clip::new(held, clip.media, t, at, hold)
+ };
+ still.look = clip.look.clone();
+ still.look.enter.kind = MotionKind::None;
+ still.look.exit.kind = MotionKind::None;
+ // Animated values hold where they were at the frame.
+ let local = (t - clip.start).min(clip.duration);
+ still.look.keys = clip.look.keys.part(local, local);
+ for prop in crate::look::Prop::ALL {
+ still.look.keys.of_mut(prop).truncate(1);
+ }
+ self.tracks[slot.track].insert_sorted(still);
+ Some(held)
+ }
+
/// The timeline with only what plays between `from` and `to`: clips
/// wholly outside it go, the rest stay as they are (so they move as they
/// do in the whole movie). Exporting a stretch cuts it from this.
pub fn window(&self, from: Time, to: Time) -> Project {
let mut out = self.clone();
out.story = crate::story::Story::default();
+ // A clip drawn on past its edges for a transition counts by where it is drawn.
+ let drawn: Vec = self
+ .clips()
+ .filter(|(_, c)| {
+ let (head, tail) = self.handles(c.id);
+ c.start - head < to && c.end() + tail > from
+ })
+ .map(|(_, c)| c.id)
+ .collect();
+ // So is a clip a kept one's transition comes from, so it plays the same.
+ let sources: Vec = drawn
+ .iter()
+ .filter_map(|id| self.cut_into(*id).and_then(|cut| cut.from))
+ .collect();
for track in &mut out.tracks {
- track.clips.retain(|c| c.start < to && c.end() > from);
+ track.clips.retain(|c| drawn.contains(&c.id) || sources.contains(&c.id));
}
out
}
@@ -923,7 +1154,8 @@ 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; all default, so older projects need nothing.
+ // `placeholder` on clips, schema 6 speed, reverse, freeze, transitions,
+ // colour and keyframes on clips; all default, so older projects need nothing.
value["schema"] = SCHEMA.into();
Ok(())
}
diff --git a/crates/splice-core/src/story.rs b/crates/splice-core/src/story.rs
index 78458f3..d601aa5 100644
--- a/crates/splice-core/src/story.rs
+++ b/crates/splice-core/src/story.rs
@@ -496,17 +496,7 @@ impl Project {
(0, room)
};
let id = ClipId(self.alloc());
- let clip = Clip {
- id,
- media,
- start,
- source_in,
- duration,
- volume: 1.0,
- look: crate::look::Look::default(),
- generator: None,
- placeholder: None,
- };
+ let clip = Clip::new(id, media, start, source_in, duration);
self.place(clip, track.unwrap_or(0));
self.set_take(take, |t| t.clip = Some(id));
self.prune_tracks();
diff --git a/crates/splice-core/src/symbol.rs b/crates/splice-core/src/symbol.rs
index f40dd41..34764ce 100644
--- a/crates/splice-core/src/symbol.rs
+++ b/crates/splice-core/src/symbol.rs
@@ -4,7 +4,6 @@
//! placeholders (clips marked with a tag) filled in for that use: other
//! words for a title, another colour, another picture or sound.
-use crate::look::Look;
use crate::model::{Clip, ClipId, Generator, Media, MediaId, MediaKind, Project, Settings, Track, TrackId};
use crate::time::Time;
use serde::{Deserialize, Serialize};
@@ -577,17 +576,7 @@ impl Project {
t.clips.retain(|c| !clips.contains(&c.id));
}
let id = ClipId(self.alloc());
- let clip = Clip {
- id,
- media,
- start,
- source_in: 0,
- duration,
- volume: 1.0,
- look: Look::default(),
- generator: None,
- placeholder: None,
- };
+ let clip = Clip::new(id, media, start, 0, duration);
let end = start + duration;
let free = (low..=high).find(|&i| self.tracks[i].clips.iter().all(|c| c.end() <= start || c.start >= end));
let index = match free {
diff --git a/crates/splice-core/src/transition.rs b/crates/splice-core/src/transition.rs
new file mode 100644
index 0000000..d5ede8d
--- /dev/null
+++ b/crates/splice-core/src/transition.rs
@@ -0,0 +1,335 @@
+//! Transitions: how one clip gives way to the next at a cut.
+//!
+//! A transition belongs to the clip coming in. With a clip just before it on
+//! the same track, it is centred on the cut: the clip going out plays on past
+//! its end and the one coming in starts before its beginning, each for half
+//! the transition (frames beyond the media hold still). With nothing before,
+//! the clip comes in over whatever is underneath during its first moments.
+//! A dip to a colour needs no extra frames: the colour rises over the clip
+//! going out and falls away from the one coming in.
+//!
+//! Every effect is an expression over clip time, so the viewer and export
+//! compute the same thing (`compose`).
+
+use crate::model::{Clip, ClipId, Project};
+use crate::time::{SECOND, Time};
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Kind {
+ /// One picture melts into the next.
+ #[default]
+ Dissolve,
+ /// Down to black, and up into the next.
+ DipBlack,
+ /// Up to white, and down into the next.
+ DipWhite,
+ /// An edge sweeps across, the new picture behind it. Named for the way it travels.
+ WipeLeft,
+ WipeRight,
+ WipeUp,
+ WipeDown,
+ /// The new picture pushes the old one off.
+ PushLeft,
+ PushRight,
+ PushUp,
+ PushDown,
+ /// The old picture rushes forward as the new one appears.
+ Zoom,
+}
+
+impl Kind {
+ pub const ALL: [Kind; 12] = [
+ Kind::Dissolve,
+ Kind::DipBlack,
+ Kind::DipWhite,
+ Kind::WipeLeft,
+ Kind::WipeRight,
+ Kind::WipeUp,
+ Kind::WipeDown,
+ Kind::PushLeft,
+ Kind::PushRight,
+ Kind::PushUp,
+ Kind::PushDown,
+ Kind::Zoom,
+ ];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Kind::Dissolve => "dissolve",
+ Kind::DipBlack => "dip-black",
+ Kind::DipWhite => "dip-white",
+ Kind::WipeLeft => "wipe-left",
+ Kind::WipeRight => "wipe-right",
+ Kind::WipeUp => "wipe-up",
+ Kind::WipeDown => "wipe-down",
+ Kind::PushLeft => "push-left",
+ Kind::PushRight => "push-right",
+ Kind::PushUp => "push-up",
+ Kind::PushDown => "push-down",
+ Kind::Zoom => "zoom",
+ }
+ }
+
+ /// As the inspector says it.
+ pub fn label(self) -> &'static str {
+ match self {
+ Kind::Dissolve => "Dissolve",
+ Kind::DipBlack => "Dip to black",
+ Kind::DipWhite => "Dip to white",
+ Kind::WipeLeft => "Wipe left",
+ Kind::WipeRight => "Wipe right",
+ Kind::WipeUp => "Wipe up",
+ Kind::WipeDown => "Wipe down",
+ Kind::PushLeft => "Push left",
+ Kind::PushRight => "Push right",
+ Kind::PushUp => "Push up",
+ Kind::PushDown => "Push down",
+ Kind::Zoom => "Zoom",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option {
+ let name = name.trim().to_lowercase().replace([' ', '_'], "-");
+ let name = match name.as_str() {
+ "crossfade" | "cross-dissolve" | "cross-fade" | "fade" | "mix" => "dissolve",
+ "dip" | "dip-to-black" | "black" | "fade-black" | "fade-to-black" => "dip-black",
+ "dip-to-white" | "white" | "flash" | "fade-white" | "fade-to-white" => "dip-white",
+ "wipe" => "wipe-left",
+ "push" | "slide" => "push-left",
+ "zoom-through" => "zoom",
+ other => other,
+ }
+ .to_string();
+ Kind::ALL.into_iter().find(|k| k.name() == name)
+ }
+
+ /// The colour a dip passes through, 0xRRGGBB.
+ pub fn dip(self) -> Option {
+ match self {
+ Kind::DipBlack => Some(0x000000),
+ Kind::DipWhite => Some(0xffffff),
+ _ => None,
+ }
+ }
+
+ /// Whether the clip going out moves (and so plays on past its end).
+ fn moves_outgoing(self) -> bool {
+ matches!(
+ self,
+ Kind::Dissolve | Kind::PushLeft | Kind::PushRight | Kind::PushUp | Kind::PushDown | Kind::Zoom
+ ) || self.is_wipe()
+ }
+
+ fn is_wipe(self) -> bool {
+ matches!(self, Kind::WipeLeft | Kind::WipeRight | Kind::WipeUp | Kind::WipeDown)
+ }
+}
+
+/// The way into a clip.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transition {
+ pub kind: Kind,
+ pub duration: Time,
+}
+
+impl Transition {
+ pub const DEFAULT_LENGTH: Time = SECOND * 3 / 5;
+ pub const LONGEST: Time = 5 * SECOND;
+
+ pub fn new(kind: Kind) -> Transition {
+ Transition {
+ kind,
+ duration: Transition::DEFAULT_LENGTH,
+ }
+ }
+}
+
+/// A transition as it plays at one cut: the clips on either side, and when.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Cut {
+ pub kind: Kind,
+ /// The clip going out; None when the clip comes in over what is beneath.
+ pub from: Option,
+ pub to: ClipId,
+ /// The stretch of timeline it takes.
+ pub start: Time,
+ pub end: Time,
+}
+
+impl Cut {
+ pub fn duration(&self) -> Time {
+ self.end - self.start
+ }
+}
+
+impl Project {
+ /// The visual clip just before `id` on its track, when it ends exactly
+ /// where `id` starts: what a transition into `id` comes from.
+ pub fn clip_before(&self, id: ClipId) -> Option<&Clip> {
+ let slot = self.slot(id)?;
+ let track = &self.tracks[slot.track];
+ let clip = &track.clips[slot.index];
+ let before = track.clips.get(slot.index.checked_sub(1)?)?;
+ let visual = self.source(before).is_some_and(|s| s.is_visual());
+ (before.end() == clip.start && visual).then_some(before)
+ }
+
+ /// The visual clip just after `id` on its track, starting where it ends.
+ pub fn clip_after(&self, id: ClipId) -> Option<&Clip> {
+ let slot = self.slot(id)?;
+ let track = &self.tracks[slot.track];
+ let clip = &track.clips[slot.index];
+ let after = track.clips.get(slot.index + 1)?;
+ let visual = self.source(after).is_some_and(|s| s.is_visual());
+ (after.start == clip.end() && visual).then_some(after)
+ }
+
+ /// The longest a transition into `id` can be: no longer than either clip.
+ pub fn longest_transition(&self, id: ClipId) -> Time {
+ let Some(clip) = self.clip(id) else { return 0 };
+ let mut most = clip.duration.min(Transition::LONGEST);
+ if let Some(before) = self.clip_before(id) {
+ most = most.min(before.duration);
+ }
+ most
+ }
+
+ /// The transition into `id`, as it plays.
+ pub fn cut_into(&self, id: ClipId) -> Option {
+ let clip = self.clip(id)?;
+ let t = clip.transition?;
+ if !self.source(clip).is_some_and(|s| s.is_visual()) {
+ return None;
+ }
+ let d = t
+ .duration
+ .clamp(2 * self.frame(), self.longest_transition(id).max(2 * self.frame()));
+ Some(match self.clip_before(id) {
+ Some(before) => Cut {
+ kind: t.kind,
+ from: Some(before.id),
+ to: id,
+ start: clip.start - d / 2,
+ end: clip.start - d / 2 + d,
+ },
+ None => Cut {
+ kind: t.kind,
+ from: None,
+ to: id,
+ start: clip.start,
+ end: clip.start + d,
+ },
+ })
+ }
+
+ /// The transition out of `id`: the one into the clip after it.
+ pub fn cut_out_of(&self, id: ClipId) -> Option {
+ let after = self.clip_after(id)?;
+ self.cut_into(after.id).filter(|c| c.from == Some(id))
+ }
+
+ /// Every transition, in timeline order.
+ pub fn cuts(&self) -> Vec {
+ let mut cuts: Vec = self.clips().filter_map(|(_, c)| self.cut_into(c.id)).collect();
+ cuts.sort_by_key(|c| c.start);
+ cuts
+ }
+
+ /// How long before its start and after its end a clip is drawn, for
+ /// transitions that need it to play on.
+ pub fn handles(&self, id: ClipId) -> (Time, Time) {
+ let head = self
+ .cut_into(id)
+ .filter(|c| c.from.is_some() && c.kind.moves_outgoing())
+ .and_then(|c| self.clip(id).map(|clip| clip.start - c.start))
+ .unwrap_or(0);
+ let tail = self
+ .cut_out_of(id)
+ .filter(|c| c.kind.moves_outgoing())
+ .and_then(|c| self.clip(id).map(|clip| c.end - clip.end()))
+ .unwrap_or(0);
+ (head.max(0), tail.max(0))
+ }
+
+ /// Sets (or with None, takes away) the transition into each clip.
+ pub fn set_transition(&mut self, ids: &[ClipId], transition: Option) {
+ for &id in ids {
+ let most = self.longest_transition(id);
+ if let Some(clip) = self.clip_mut(id) {
+ clip.transition = transition.map(|t| Transition {
+ kind: t.kind,
+ duration: t.duration.clamp(SECOND / 10, most.max(SECOND / 10)),
+ });
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::model::{Media, MediaId, MediaKind};
+ use std::path::PathBuf;
+
+ fn two_clips() -> (Project, ClipId, ClipId) {
+ let mut p = Project::default();
+ let m = p.add_media(Media {
+ id: MediaId(0),
+ name: "v".into(),
+ hash: "a".into(),
+ ext: "mp4".into(),
+ kind: MediaKind::Video,
+ duration: 4 * SECOND,
+ width: 1920,
+ height: 1080,
+ has_audio: false,
+ source: PathBuf::new(),
+ recipe: None,
+ content: None,
+ });
+ let a = p.append_clip(m).unwrap();
+ let b = p.append_clip(m).unwrap();
+ (p, a, b)
+ }
+
+ #[test]
+ fn a_transition_is_centred_on_the_cut() {
+ let (mut p, a, b) = two_clips();
+ p.set_transition(&[b], Some(Transition::new(Kind::Dissolve)));
+ let cut = p.cut_into(b).unwrap();
+ assert_eq!(cut.from, Some(a));
+ assert_eq!((cut.start, cut.end), (4 * SECOND - 300_000, 4 * SECOND + 300_000));
+ assert_eq!(p.handles(a), (0, 300_000), "the clip going out plays on");
+ assert_eq!(p.handles(b), (300_000, 0), "the one coming in starts early");
+ assert_eq!(p.cut_out_of(a), Some(cut));
+ }
+
+ #[test]
+ fn dips_need_no_extra_frames_and_lengths_are_kept_short_enough() {
+ let (mut p, a, b) = two_clips();
+ p.set_transition(
+ &[b],
+ Some(Transition {
+ kind: Kind::DipBlack,
+ duration: 60 * SECOND,
+ }),
+ );
+ assert_eq!(p.clip(b).unwrap().transition.unwrap().duration, 4 * SECOND);
+ assert_eq!(p.handles(a), (0, 0));
+ // With nothing before, it plays over the clip's first moments.
+ p.set_transition(&[a], Some(Transition::new(Kind::PushLeft)));
+ let first = p.cut_into(a).unwrap();
+ assert_eq!((first.from, first.start, first.end), (None, 0, 600_000));
+ assert_eq!(p.handles(a).0, 0);
+ }
+
+ #[test]
+ fn kinds_read_loosely() {
+ assert_eq!(Kind::parse("crossfade"), Some(Kind::Dissolve));
+ assert_eq!(Kind::parse("Dip to white"), Some(Kind::DipWhite));
+ assert_eq!(Kind::parse("push_up"), Some(Kind::PushUp));
+ assert_eq!(Kind::parse("spin"), None);
+ }
+}
diff --git a/crates/splice-core/tests/render.rs b/crates/splice-core/tests/render.rs
new file mode 100644
index 0000000..a291adf
--- /dev/null
+++ b/crates/splice-core/tests/render.rs
@@ -0,0 +1,463 @@
+//! Export plans run through ffmpeg: transitions, speed, reverse, held
+//! frames, keyframes and colour. Each renders a tiny movie and measures it.
+//! Skipped when ffmpeg is not installed.
+
+use splice_core::export::{self, Quality, Rendered, Resolve};
+use splice_core::look::{Ease, Preset, Prop};
+use splice_core::model::{Clip, Generator, Media, MediaId, MediaKind, Project};
+use splice_core::time::{SECOND, Time, secs};
+use splice_core::transition::{Kind, Transition};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn tool(name: &str) -> Option {
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
+ .iter()
+ .map(|d| Path::new(d).join(name))
+ .find(|p| p.exists())
+}
+
+struct Files(PathBuf);
+
+impl Resolve for Files {
+ fn media(&self, media: &Media) -> Option {
+ Some(self.0.join(format!("{}.{}", media.hash, media.ext)))
+ }
+ fn generated(&self, _: &Clip, _: &Generator) -> Option {
+ None
+ }
+}
+
+fn scratch(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("splice-render-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+/// A 6 s clip whose brightness climbs with time (about 40 levels a second),
+/// with a tone, so where in it a frame is from can be measured.
+fn ramp(dir: &Path, hash: &str, colour: bool) -> Option {
+ ramp_with_sound(dir, hash, colour, 6.0)
+}
+
+/// Like `ramp`, its sound running `sound` seconds (past the picture's 6 when longer).
+fn ramp_with_sound(dir: &Path, hash: &str, colour: bool, sound: f64) -> Option {
+ let ffmpeg = tool("ffmpeg")?;
+ let path = dir.join(format!("{hash}.mp4"));
+ let picture = if colour {
+ "color=c=0x3060c0:s=160x90:r=30:d=6,format=yuv420p".to_string()
+ } else {
+ "color=c=black:s=160x90:r=30:d=6,format=gray,geq=lum='16+T*36',format=yuv420p".to_string()
+ };
+ let ok = Command::new(ffmpeg)
+ .args([
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ &picture,
+ ])
+ .args(["-f", "lavfi", "-i", "sine=f=440:d=6:sample_rate=48000"])
+ .args([
+ "-c:v",
+ "libx264",
+ "-g",
+ "1",
+ "-pix_fmt",
+ "yuv420p",
+ "-c:a",
+ "aac",
+ "-shortest",
+ ])
+ .arg(&path)
+ .status()
+ .ok()?
+ .success();
+ ok.then(|| Media {
+ id: MediaId(0),
+ name: hash.into(),
+ hash: hash.into(),
+ ext: "mp4".into(),
+ kind: MediaKind::Video,
+ duration: (sound.max(6.0) * SECOND as f64) as Time,
+ width: 160,
+ height: 90,
+ has_audio: true,
+ source: path,
+ recipe: None,
+ content: None,
+ })
+}
+
+fn project() -> Project {
+ let mut p = Project::default();
+ p.settings.width = 160;
+ p.settings.height = 90;
+ p
+}
+
+fn render(p: &Project, dir: &Path, name: &str) -> PathBuf {
+ let out = dir.join(format!("{name}.mp4"));
+ let plan = export::plan(p, &Files(dir.to_path_buf()), &out, Quality::Draft).unwrap();
+ if std::env::var("SHOW_PLAN").is_ok() {
+ eprintln!("{name}: {}", plan.args.join(" "));
+ }
+ run_plan(&plan, name);
+ out
+}
+
+/// Runs a plan's preparation, then the plan, then tidies up.
+fn run_plan(plan: &export::Plan, name: &str) {
+ if let Some(dir) = &plan.scratch {
+ std::fs::create_dir_all(dir).unwrap();
+ }
+ for (path, text) in &plan.files {
+ std::fs::write(path, text).unwrap();
+ }
+ for command in &plan.prepare {
+ run(command, &format!("{name} (preparing)"));
+ }
+ run(&plan.args, name);
+ if let Some(dir) = &plan.scratch {
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
+
+fn run(args: &[String], name: &str) {
+ let output = Command::new(tool("ffmpeg").unwrap()).args(args).output().unwrap();
+ assert!(
+ output.status.success(),
+ "{name} failed: {}\n{}",
+ String::from_utf8_lossy(&output.stderr),
+ args.join(" ")
+ );
+}
+
+fn duration(path: &Path) -> f64 {
+ let out = Command::new(tool("ffprobe").unwrap())
+ .args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
+ .arg(path)
+ .output()
+ .unwrap();
+ String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
+}
+
+/// Average brightness (0–255) and saturation of the frame at `t` seconds.
+fn measure(path: &Path, t: f64) -> (f64, f64) {
+ let out = Command::new(tool("ffmpeg").unwrap())
+ .args(["-hide_banner", "-ss", &format!("{t:.3}"), "-i"])
+ .arg(path)
+ .args(["-frames:v", "1", "-vf", "signalstats,metadata=print", "-f", "null", "-"])
+ .output()
+ .unwrap();
+ let text = String::from_utf8_lossy(&out.stderr);
+ let tag = |name: &str| {
+ text.lines()
+ .find_map(|l| {
+ l.split_once(&format!("lavfi.signalstats.{name}="))
+ .map(|(_, v)| v.trim().parse::().unwrap())
+ })
+ .unwrap_or_else(|| panic!("no {name} at {t}s of {path:?}: {text}"))
+ };
+ (tag("YAVG"), tag("SATAVG"))
+}
+
+/// Like `measure`, of one part of the frame (an ffmpeg crop).
+fn measure_crop(path: &Path, t: f64, crop: &str) -> (f64, f64) {
+ let out = Command::new(tool("ffmpeg").unwrap())
+ .args(["-hide_banner", "-ss", &format!("{t:.3}"), "-i"])
+ .arg(path)
+ .args([
+ "-frames:v",
+ "1",
+ "-vf",
+ &format!("{crop},signalstats,metadata=print"),
+ "-f",
+ "null",
+ "-",
+ ])
+ .output()
+ .unwrap();
+ let text = String::from_utf8_lossy(&out.stderr);
+ let tag = |name: &str| {
+ text.lines()
+ .find_map(|l| {
+ l.split_once(&format!("lavfi.signalstats.{name}="))
+ .map(|(_, v)| v.trim().parse::().unwrap())
+ })
+ .unwrap_or_else(|| panic!("no {name} at {t}s of {path:?}: {text}"))
+ };
+ (tag("YAVG"), tag("SATAVG"))
+}
+
+/// Loudness (RMS, dB) of 40 ms of sound from `t` seconds.
+fn loudness(path: &Path, t: f64) -> f64 {
+ let out = Command::new(tool("ffmpeg").unwrap())
+ .args(["-hide_banner", "-ss", &format!("{t:.3}"), "-t", "0.04", "-i"])
+ .arg(path)
+ .args([
+ "-vn",
+ "-af",
+ "astats=metadata=1:reset=0,ametadata=print:key=lavfi.astats.Overall.RMS_level",
+ "-f",
+ "null",
+ "-",
+ ])
+ .output()
+ .unwrap();
+ let text = String::from_utf8_lossy(&out.stderr);
+ text.lines()
+ .rev()
+ .filter_map(|l| {
+ l.split_once("RMS_level=")
+ .map(|(_, v)| v.trim().parse::().unwrap_or(-200.0))
+ })
+ .next()
+ .unwrap_or(-200.0)
+}
+
+fn brightness(path: &Path, t: f64) -> f64 {
+ measure(path, t).0
+}
+
+/// What the source ramp measures `s` seconds in.
+fn level(s: f64) -> f64 {
+ 30.0 + 30.6 * s
+}
+
+fn near(a: f64, b: f64, by: f64) -> bool {
+ (a - b).abs() <= by
+}
+
+macro_rules! needs_ffmpeg {
+ ($dir:expr, $hash:expr, $colour:expr) => {
+ match ramp(&$dir, $hash, $colour) {
+ Some(media) => media,
+ None => {
+ eprintln!("ffmpeg not found; skipped");
+ return;
+ }
+ }
+ };
+}
+
+#[test]
+fn speed_reverse_and_held_frames_play_the_right_moments() {
+ let dir = scratch("speed");
+ let media = needs_ffmpeg!(dir, "ramp", false);
+ let mut p = project();
+ let m = p.add_media(media);
+
+ // Twice as fast: 6 s of source in 3 s, one second showing source second two.
+ let fast = p.append_clip(m).unwrap();
+ p.set_speed(&[fast], 2.0);
+ assert_eq!(p.clip(fast).unwrap().duration, 3 * SECOND);
+ let out = render(&p, &dir, "fast");
+ assert!(near(duration(&out), 3.0, 0.1), "{}", duration(&out));
+ assert!(
+ near(brightness(&out, 1.0), level(2.0), 8.0),
+ "{}",
+ brightness(&out, 1.0)
+ );
+
+ // Backwards: bright first, dark last, every piece in order.
+ p.set_speed(&[fast], 1.0);
+ p.set_reverse(&[fast], true);
+ let out = render(&p, &dir, "reverse");
+ assert!(near(duration(&out), 6.0, 0.1));
+ assert!(
+ near(brightness(&out, 0.5), level(5.5), 8.0),
+ "{}",
+ brightness(&out, 0.5)
+ );
+ assert!(
+ near(brightness(&out, 3.0), level(3.0), 8.0),
+ "{}",
+ brightness(&out, 3.0)
+ );
+ assert!(
+ near(brightness(&out, 5.5), level(0.5), 8.0),
+ "{}",
+ brightness(&out, 5.5)
+ );
+
+ // A held frame: two seconds of source second one, then the rest moves along.
+ p.set_reverse(&[fast], false);
+ let held = p.freeze_frame(fast, SECOND, 2 * SECOND).unwrap();
+ assert!(p.clip(held).unwrap().frozen);
+ let out = render(&p, &dir, "held");
+ assert!(near(duration(&out), 8.0, 0.1), "{}", duration(&out));
+ for t in [1.2, 2.0, 2.8] {
+ assert!(
+ near(brightness(&out, t), level(1.0), 8.0),
+ "{t}: {}",
+ brightness(&out, t)
+ );
+ }
+ assert!(
+ near(brightness(&out, 4.0), level(2.0), 8.0),
+ "{}",
+ brightness(&out, 4.0)
+ );
+}
+
+#[test]
+fn transitions_render_and_stills_inside_them_match() {
+ let dir = scratch("transitions");
+ let dark = needs_ffmpeg!(dir, "ramp", false);
+ let blue = needs_ffmpeg!(dir, "blue", true);
+ let mut p = project();
+ let a = p.add_media(dark);
+ let b = p.add_media(blue);
+ let first = p.append_clip(a).unwrap();
+ p.trim_end(first, 3 * SECOND);
+ let second = p.append_clip(b).unwrap();
+ for kind in Kind::ALL {
+ p.set_transition(&[second], Some(Transition::new(kind)));
+ let out = render(&p, &dir, kind.name());
+ assert!(near(duration(&out), 9.0, 0.1), "{}: {}", kind.name(), duration(&out));
+ // Long after the cut, only the blue clip.
+ let (_, sat) = measure(&out, 5.0);
+ assert!(sat > 30.0, "{}: {sat}", kind.name());
+ }
+ // Halfway through a push, the old picture fills the left half and the new the right.
+ p.set_transition(&[second], Some(Transition::new(Kind::PushLeft)));
+ let out = render(&p, &dir, "push-check");
+ let (_, left) = measure_crop(&out, 3.0, "crop=60:90:0:0");
+ let (_, right) = measure_crop(&out, 3.0, "crop=60:90:100:0");
+ assert!(left < 5.0 && right > 30.0, "left {left}, right {right}");
+ // A dip is black right at the cut, and quiet.
+ p.set_transition(&[second], Some(Transition::new(Kind::DipBlack)));
+ let out = render(&p, &dir, "dip-check");
+ assert!(brightness(&out, 3.0) < 25.0, "{}", brightness(&out, 3.0));
+ assert!(loudness(&out, 2.98) < -35.0, "{}", loudness(&out, 2.98));
+ // A dissolve halfway through mixes the two, and a still there says the same.
+ p.set_transition(&[second], Some(Transition::new(Kind::Dissolve)));
+ let out = render(&p, &dir, "dissolve-check");
+ let (_, movie_sat) = measure(&out, 3.0);
+ // The sound crossfades: as loud at the cut as either side of it.
+ let (at_cut, before) = (loudness(&out, 2.98), loudness(&out, 1.5));
+ assert!(
+ (at_cut - before).abs() < 4.0,
+ "at the cut {at_cut} dB, before {before} dB"
+ );
+ let still = dir.join("still.png");
+ let plan = export::plan_still(&p, &Files(dir.clone()), 3 * SECOND, &still).unwrap();
+ run_plan(&plan, "still");
+ let (_, still_sat) = measure(&still, 0.0);
+ assert!(movie_sat > 5.0 && movie_sat < 40.0, "half blue: {movie_sat}");
+ assert!(near(movie_sat, still_sat, 6.0), "movie {movie_sat}, still {still_sat}");
+ // A stretch cut across the transition plays it too.
+ let part = dir.join("part.mp4");
+ let plan = export::plan_range(&p, &Files(dir.clone()), &part, Quality::Draft, 2 * SECOND, 4 * SECOND).unwrap();
+ run_plan(&plan, "range");
+ let (_, part_sat) = measure(&part, 1.0);
+ assert!(near(part_sat, movie_sat, 6.0), "range {part_sat}, movie {movie_sat}");
+}
+
+#[test]
+fn keyframes_and_colour_reach_the_file() {
+ let dir = scratch("keys");
+ let media = needs_ffmpeg!(dir, "blue", true);
+ let mut p = project();
+ let m = p.add_media(media);
+ let clip = p.append_clip(m).unwrap();
+ // Fades up from nothing over two seconds, then holds.
+ p.set_look(clip, |look| {
+ look.keys.set(Prop::Opacity, 0, 0.0, Some(Ease::Linear));
+ look.keys.set(Prop::Opacity, 2 * SECOND, 1.0, None);
+ look.keys.set(Prop::Scale, 0, 1.0, None);
+ look.keys.set(Prop::Scale, 4 * SECOND, 0.5, None);
+ });
+ let out = render(&p, &dir, "keys");
+ // In the middle, which the picture always covers.
+ let middle = "crop=20:20:70:35";
+ let (early, _) = measure_crop(&out, 0.2, middle);
+ let (half, _) = measure_crop(&out, 1.0, middle);
+ let (full, _) = measure_crop(&out, 3.0, middle);
+ assert!(early < half && half < full, "{early} {half} {full}");
+ // Half size by the end, and over to the right: the corners go black.
+ let (corner, _) = measure_crop(&out, 3.9, "crop=20:20:0:0");
+ assert!(corner < 20.0, "the shrunk picture leaves the corner black: {corner}");
+ let look = &p.clip(clip).unwrap().look;
+ assert!((look.value(Prop::Opacity, SECOND) - 0.5).abs() < 1e-6);
+ // Black and white leaves no colour.
+ p.set_look(clip, |look| {
+ look.keys = Default::default();
+ look.grade.preset = Preset::Mono;
+ });
+ let out = render(&p, &dir, "mono");
+ let (_, sat) = measure(&out, 1.0);
+ assert!(sat < 2.0, "{sat}");
+ let _: Time = 0;
+ let _ = secs(0);
+}
+
+#[test]
+fn files_whose_sound_runs_past_their_picture() {
+ let dir = scratch("long-sound");
+ let Some(media) = ramp_with_sound(&dir, "long", false, 6.5) else {
+ eprintln!("ffmpeg not found; skipped");
+ return;
+ };
+ let mut p = project();
+ let m = p.add_media(media);
+ let clip = p.append_clip(m).unwrap();
+ assert_eq!(p.clip(clip).unwrap().duration, 6_500_000, "the container's length");
+ // Backwards, the picture stays in step with the source it shows.
+ p.set_reverse(&[clip], true);
+ let out = render(&p, &dir, "reverse");
+ assert!(
+ near(brightness(&out, 3.0), level(3.5), 8.0),
+ "{}",
+ brightness(&out, 3.0)
+ );
+ // A frame held past the picture's end is its last frame, not nothing.
+ p.set_reverse(&[clip], false);
+ let held = p.freeze_frame(clip, 6_400_000, SECOND).unwrap();
+ let at = p.clip(held).unwrap().start;
+ let out = render(&p, &dir, "held");
+ let t = secs(at) + 0.5;
+ assert!(
+ near(brightness(&out, t), level(5.95), 8.0),
+ "{t}: {}",
+ brightness(&out, t)
+ );
+}
+
+#[test]
+fn transitions_never_flash_more_than_their_clips() {
+ let dir = scratch("cover");
+ let blue = needs_ffmpeg!(dir, "blue", true);
+ let dark = needs_ffmpeg!(dir, "ramp", false);
+ let mut p = project();
+ let b = p.add_media(blue);
+ let d = p.add_media(dark);
+ let first = p.append_clip(b).unwrap();
+ // A small picture coming in: the blue one must fade, not vanish at the end.
+ let second = p.append_clip(d).unwrap();
+ p.set_look(second, |l| l.scale = 0.5);
+ p.set_transition(&[second], Some(Transition::new(Kind::Dissolve)));
+ let out = render(&p, &dir, "dissolve-small");
+ let corner = "crop=20:20:0:0";
+ let (_, before) = measure_crop(&out, 6.1, corner);
+ let (_, after) = measure_crop(&out, 6.35, corner);
+ assert!(
+ before > 5.0 && before < 60.0 && after < 15.0,
+ "fades out: {before} then {after}"
+ );
+ // A dip into a picture in picture over the blue one dims only the picture.
+ p.delete(&[second]);
+ p.trim_end(first, 6 * SECOND);
+ let pip = p.insert_clip(d, 1, 2 * SECOND).unwrap();
+ p.set_look(pip, |l| l.scale = 0.3);
+ p.set_transition(&[pip], Some(Transition::new(Kind::DipBlack)));
+ let out = render(&p, &dir, "dip-pip");
+ let (_, sat) = measure_crop(&out, 2.05, corner);
+ assert!(sat > 30.0, "the blue around it stays blue: {sat}");
+ let _ = first;
+}
diff --git a/skill/splice/SKILL.md b/skill/splice/SKILL.md
index bfc75a0..3d7fa4b 100644
--- a/skill/splice/SKILL.md
+++ b/skill/splice/SKILL.md
@@ -120,6 +120,8 @@ splice ctl look --clip 7 --mask ellipse --feather 0.15 # a soft roun
splice ctl look --clip 7 --crop-left 0.2 --crop-bottom 0.1 # cut edges away (--crop none)
splice ctl look --clip 9 --key green --similarity 0.18 # green screen (blue, #rrggbb too)
splice ctl look --clip 3 --drift zoom-in --enter fade --enter-for 1
+splice ctl look --clip 3 --grade film --warmth 0.3 --contrast 0.2 # colour (--grade none to clear)
+splice ctl look --all --grade teal-orange # every video and picture alike
```
- A title's `--size` is the letter height in canvas pixels. Text uses real
@@ -133,6 +135,41 @@ splice ctl look --clip 3 --drift zoom-in --enter fade --enter-for 1
the whole clip.
- Keys and masks are for video and pictures; masks work on titles and
colours too. Mask numbers are fractions of the clip's own frame.
+- Colour is for video and pictures: `--grade` is none, warm, cool, vivid,
+ muted, faded, film, teal-orange, mono or noir; `--exposure` (stops),
+ `--contrast`, `--saturation`, `--warmth` and `--tint` run from −1 to 1, 0
+ untouched. Grade the whole movie alike (`look --all`) rather than clip by
+ clip unless a shot needs matching.
+
+### Transitions, speed and keyframes
+
+```sh
+splice ctl transition --clip 8 --kind dissolve --for 0.8 # into clip 8 from the clip before it
+splice ctl transition --all --kind dip-black --track 1 # every cut on the bottom track
+splice ctl transition --clip 8 --none
+splice ctl speed --clip 5 --rate 2 # twice as fast: half as long
+splice ctl speed --clip 5 --rate 0.5 --reverse # slow and backwards (--forwards undoes)
+splice ctl freeze --clip 5 --at 12.4 --for 2 # hold that frame for 2s
+splice ctl keyframe --clip 7 --at 10 --scale 1 --x 0 # keyframes at timeline times
+splice ctl keyframe --clip 7 --at 14 --scale 1.4 --x 0.1 --ease in-out
+splice ctl keyframe --clip 7 --at 14 --drop # or --clear for all of them
+```
+
+- A transition belongs to the clip coming in. With a clip ending exactly
+ where it starts on the same track, it is centred on that cut and both
+ clips play on for half of it (frames beyond the media hold still). With a
+ gap before, it plays over what is beneath as the clip begins. Kinds:
+ dissolve, dip-black, dip-white, wipe-left/right/up/down,
+ push-left/right/up/down, zoom. Use them sparingly: a dissolve for time
+ passing, a dip to black between sections, cuts everywhere else.
+- `speed` keeps the stretch of source and changes the clip's length; later
+ clips on the same track move to match, other tracks do not, so check that
+ music and titles above still line up (`read`). Sound keeps its pitch.
+ A held frame (`freeze`) is silent and stretches like a picture.
+- Keyframes animate x, y, scale and opacity; a value with keyframes ignores
+ its plain `look` value. Each keyframe's `--ease` shapes the glide to the
+ next one. Animate the picture, not the frame: a slow push in of 1 to 1.15
+ over a shot reads as life; big moves read as effects.
- Text and colour clips are on the timeline like any other clip: move,
trim, split and delete them the same way. `read` shows their `text` and
`look`.