Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
24 changes: 24 additions & 0 deletions crates/splice-app/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ actions!(
NewScene,
MakeSymbol,
BreakApart,
AddTransition,
FreezeFrame,
ReverseClip,
AddKeyframe,
ShowSymbols,
ShowMedia,
ShowTextStyles,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -191,6 +198,11 @@ pub fn menus() -> Vec<Menu> {
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(),
Expand Down Expand Up @@ -289,6 +301,18 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box<dyn Action>)> {
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)),
Expand Down
31 changes: 28 additions & 3 deletions crates/splice-app/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pcm>,
/// 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)]
Expand Down Expand Up @@ -162,10 +181,16 @@ fn fill<S: Copy + Default>(mix: &Mutex<Mix>, 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);
Expand Down
9 changes: 8 additions & 1 deletion crates/splice-app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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");
Expand Down
34 changes: 30 additions & 4 deletions crates/splice-app/src/ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -425,12 +447,13 @@ fn build(args: &[String]) -> Result<Value, String> {
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::<f64>() {
Ok(v) => json!(v),
Expand Down Expand Up @@ -517,9 +540,12 @@ fn build(args: &[String]) -> Result<Value, String> {
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..]));
}
Expand All @@ -539,7 +565,7 @@ fn build(args: &[String]) -> Result<Value, String> {
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::<u64>().ok()) {
request.insert("version".into(), json!(id));
Expand Down
Loading
Loading