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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/splice-core", "crates/splice-app"]

[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.90"
license = "MIT"
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,52 @@ with every clip of it following.
Renders are cached by what they are made from, and kept in the project file
so it still opens anywhere.

## Symbols

A **symbol** is a piece of a movie kept for reuse in every project: an intro,
a lower third, a logo sting, an end card. Select the clips and choose **Make
Symbol** (⌥⌘K, in the Edit menu, or **+** on the Symbols shelf). Name it,
and tick the titles each use can change.
It goes into your symbols, and the clips become one clip on the timeline: a
*use* of the symbol. Their layering, looks and motion stay as they were.

- **Placeholders** are what each use can change. When you make a symbol, its
titles become placeholders ("Title", "Title 2"). In the symbol's own window,
switch on **Each use can change this** in the inspector to make a colour, a
picture or video, or a sound a placeholder too. Give two clips the same
placeholder name and they change together. Renaming a placeholder keeps what
uses filled in.
- **Using one**: the **Symbols** shelf shows each symbol with a poster and its
length. Click it to add it at the playhead, or drag it onto the timeline.
Select the use and fill in its placeholders, which the inspector shows first:
type words (the picture follows as you type), pick a swatch or type a hex
colour, or pick, import or drop media. Every use has its own fills, including
one you duplicate. A use is named by its words, such as "Lower third · Grace
Hopper".
- **Changing one**: **Edit** opens the symbol in its own window, an ordinary
Splice project with a banner and a see-through stage. Changes reach other
movies when you **publish** them with ⌘S. The status bar then says a newer
version is there, and out-of-date uses are marked on the timeline. A use
offers **Update** (or **Update all**), keeps what you
filled in, and says beforehand if anything would be dropped. Undo brings it
back.
- **Other shapes**: a 16:9 symbol in a 9:16 movie keeps its shape by default,
rendered sharp at the movie's size. **Fit this movie** lays it out for the
movie's own shape instead.
- **Longer or shorter**: drag the end of a use to make it run longer or
shorter. Its **hold** stretches, so the entrance and exit keep their timing.
Set the hold in the symbol's own window (Canvas inspector: Starts here, Ends
here); without one, the whole symbol stretches evenly.
- **Break apart** (⌥⌘B) turns a use back into its clips, to edit them freely.
- **Managing them**: the **⋯** on a symbol's card has Edit, Rename, Duplicate,
Show in Finder and Move to Trash. Symbols live in
`~/Library/Application Support/Splice/Symbols`, one `.splice` file each.
Each symbol keeps its own identity, so renaming it in Finder doesn't
disconnect its uses.
- A project keeps its own copy of every symbol it uses, with the media, so it
still opens on a Mac without them. **Keep in my symbols** puts that copy back
in your symbols. Renders of uses stay in the cache, not the project file.

## Story and recording

**Story** (⌘3, or Edit / Story in the title bar) plans the movie before and
Expand Down
15 changes: 15 additions & 0 deletions crates/splice-app/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ actions!(
RecordScreen,
PasteReference,
NewScene,
MakeSymbol,
BreakApart,
ShowSymbols,
ShowMedia,
ShowTextStyles,
ShowColours,
Expand Down Expand Up @@ -101,6 +104,8 @@ pub fn bind(cx: &mut App) {
KeyBinding::new("cmd-t", AddText, G),
KeyBinding::new("cmd-3", ToggleStory, G),
KeyBinding::new("cmd-shift-r", RecordScreen, G),
KeyBinding::new("cmd-alt-k", MakeSymbol, G),
KeyBinding::new("cmd-alt-b", BreakApart, G),
KeyBinding::new("b", AddBeat, W),
KeyBinding::new("c", AddCue, W),
KeyBinding::new("cmd-v", PasteReference, W),
Expand Down Expand Up @@ -186,6 +191,9 @@ pub fn menus() -> Vec<Menu> {
MenuItem::action("Ripple Delete", RippleDelete),
MenuItem::action("Duplicate", Duplicate),
MenuItem::separator(),
MenuItem::action("Make Symbol…", MakeSymbol),
MenuItem::action("Break Symbol Apart", BreakApart),
MenuItem::separator(),
MenuItem::action("Select All", SelectAll),
MenuItem::action("Deselect", Deselect),
MenuItem::separator(),
Expand Down Expand Up @@ -275,6 +283,13 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box<dyn Action>)> {
("Choose font (selected title)", "", Box::new(ChooseFont)),
("New score (music to compose)", "", Box::new(NewScore)),
("New scene (animation or diagram)", "", Box::new(NewScene)),
(
"Make a symbol from the selection (reuse it anywhere)",
"⌥⌘K",
Box::new(MakeSymbol),
),
("Break a symbol apart into its clips", "⌥⌘B", Box::new(BreakApart)),
("Show symbols", "", Box::new(ShowSymbols)),
("Story view / edit view", "⌘3", Box::new(ToggleStory)),
("Add a story beat", "B", Box::new(AddBeat)),
("Mark a cue at the playhead", "C", Box::new(AddCue)),
Expand Down
75 changes: 70 additions & 5 deletions crates/splice-app/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,48 @@ struct Mix {
/// Timeline frame being played.
position: u64,
voices: Vec<Voice>,
limiter: Limiter,
}

/// Holds the mix under full scale when clips overlap, instead of clipping:
/// the gain drops at once to meet a peak and recovers over a few tens of
/// milliseconds. Export does the same with ffmpeg's `alimiter`.
struct Limiter {
gain: f32,
/// How much of the way back to unity the gain recovers each frame.
release: f32,
}

impl Default for Limiter {
fn default() -> Limiter {
Limiter::new(48_000)
}
}

impl Limiter {
const CEILING: f32 = 0.97;

fn new(rate: u32) -> Limiter {
Limiter {
gain: 1.0,
release: 1.0 / (rate as f32 * 0.08),
}
}

fn apply(&mut self, left: f32, right: f32) -> (f32, f32) {
let peak = left.abs().max(right.abs());
let need = if peak > Self::CEILING {
Self::CEILING / peak
} else {
1.0
};
if need < self.gain {
self.gain = need;
} else {
self.gain += (1.0 - self.gain) * self.release;
}
(left * self.gain, right * self.gain)
}
}

pub struct AudioOut {
Expand All @@ -36,11 +78,14 @@ impl AudioOut {
pub fn open() -> AudioOut {
let mix = Arc::new(Mutex::new(Mix::default()));
match build(mix.clone()) {
Ok((stream, rate)) => AudioOut {
_stream: Some(stream),
mix,
rate,
},
Ok((stream, rate)) => {
mix.lock().unwrap().limiter = Limiter::new(rate);
AudioOut {
_stream: Some(stream),
mix,
rate,
}
}
Err(error) => {
eprintln!("splice: no audio output ({error})");
AudioOut {
Expand All @@ -60,6 +105,7 @@ impl AudioOut {
let mut mix = self.mix.lock().unwrap();
mix.voices = voices;
mix.position = self.frames(at);
mix.limiter.gain = 1.0;
mix.playing = true;
}

Expand Down Expand Up @@ -107,6 +153,7 @@ fn fill<S: Copy + Default>(mix: &Mutex<Mix>, out: &mut [S], channels: usize, con
out.fill(S::default());
return;
}
let mix = &mut *mix;
let start = mix.position;
for (i, frame) in out.chunks_mut(channels).enumerate() {
let t = start + i as u64;
Expand All @@ -121,10 +168,28 @@ fn fill<S: Copy + Default>(mix: &Mutex<Mix>, out: &mut [S], channels: usize, con
right += voice.pcm.samples[index + 1] as f32 / 32768.0 * voice.volume;
}
}
let (left, right) = mix.limiter.apply(left, right);
let (left, right) = (left.clamp(-1.0, 1.0), right.clamp(-1.0, 1.0));
for (c, sample) in frame.iter_mut().enumerate() {
*sample = convert(if c % 2 == 0 { left } else { right });
}
}
mix.position += (out.len() / channels) as u64;
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_limiter_holds_peaks_and_recovers() {
let mut limiter = Limiter::new(48_000);
assert_eq!(limiter.apply(0.5, -0.5), (0.5, -0.5), "quiet sound passes untouched");
let (l, r) = limiter.apply(1.6, -0.8);
assert!(l <= Limiter::CEILING + 1e-6 && r.abs() < 0.5);
for _ in 0..48_000 {
limiter.apply(0.1, 0.1);
}
assert!(limiter.gain > 0.99, "a second later the gain is back");
}
}
1 change: 1 addition & 0 deletions crates/splice-app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ fn new(path: &Path, media: &[String]) -> anyhow::Result<()> {
has_audio: probe.has_audio,
source: std::fs::canonicalize(&file).unwrap_or(file.clone()),
recipe: None,
content: None,
});
project.append_clip(id);
}
Expand Down
81 changes: 73 additions & 8 deletions crates/splice-app/src/ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,38 @@ Scenes (animation and diagrams: layers of shapes, lines, paths and words that mo
scene remove --media M --remove NAME [--wait]
scene check --file scene.json [--duration D]
validate without changing anything: errors, warnings, an outline
open --media M open a score or scene in Splice's own editor
open --media M open a score or scene in Splice's own editor (a symbol: its own window)

Symbols (pieces kept for reuse in any movie: intros, lower thirds, logos)
symbol list your symbols: names, ids, lengths, canvas sizes, placeholders
symbol read --name N one of your symbols, without using it
symbol make [--clip C,…] [--name N] [--keep] [--wait]
make the clips (default: the selection) a symbol; they become one use
of it unless --keep. Titles become placeholders ("Title", "Title 2")
symbol make --media M [--name N] keep one piece of media (or a score, a scene) as a symbol
symbol add --name N [--at T] [--track N] [--wait]
use a symbol (default: at the playhead, over what is there)
symbol read --media M|--clip C a use: its placeholders, what each holds, how it stands
against your symbols (current, newer, different, missing)
symbol fill --media M|--clip C --label L (--text WORDS | --color C | --use MEDIA | --reset) [--wait]
fill one placeholder for this use only; it re-renders
symbol length --media M|--clip C --duration D [--wait]
make a use run longer or shorter: its hold stretches (so does dragging
the end of a whole use on the timeline)
symbol fit --media M|--clip C --as canvas|own [--wait]
in a movie of another shape: lay it out for this movie, or keep its own
symbol update --media M|--clip C take the published version, keeping fills that still fit
symbol update --all [--name N] update every out-of-date use (of N)
symbol break --clip C turn a use back into its clips
symbol keep --media M [--name N] put a use's own copy in your symbols (when it is missing)
symbol edit --name N|--media M open the symbol in its own window; save there to publish
symbol rename --name N --new NEW | duplicate --name N --new NEW | delete --name N
manage your symbols (delete moves it to the Trash)
placeholder --clip C [--label L] | --none
in a symbol's own window: mark what each use can change (no label: named
by what it is); the same label joins placeholders. Then `save` publishes
hold --start T --end T | --clear in a symbol's own window: the part a longer or shorter use
stretches (default: all of it, evenly)

Story (planning: beats end to end along the timeline, and cues)
story read beats (id, start, length, title, story, capture, coverage) and cues
Expand Down Expand Up @@ -424,12 +455,12 @@ fn build(args: &[String]) -> Result<Value, String> {
clips.push(json!(id.trim().parse::<u64>().map_err(|_| "--clip expects clip ids")?));
}
}
"--media" => {
"--media" | "--use" => {
let m = next(arg)?;
request.insert("media".into(), m.parse::<u64>().map_or(json!(m), |id| json!(id)));
request.insert(arg[2..].into(), m.parse::<u64>().map_or(json!(m), |id| json!(id)));
}
"--text" | "--label" | "--name" | "--aspect" | "--layer" | "--into" | "--remove" | "--json" | "--add"
| "--title" | "--story" | "--capture" | "--region" | "--note" => {
"--text" | "--label" | "--name" | "--new" | "--as" | "--aspect" | "--layer" | "--into" | "--remove"
| "--json" | "--add" | "--title" | "--story" | "--capture" | "--region" | "--note" => {
request.insert(arg[2..].into(), json!(value(next(arg)?)?));
}
"--file" => {
Expand All @@ -453,7 +484,7 @@ 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" => {
"--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" => {
request.insert(arg[2..].into(), json!(true));
}
"--draft" | "--high" => {
Expand Down Expand Up @@ -538,6 +569,12 @@ fn build(args: &[String]) -> Result<Value, String> {
command.as_str()
}
"open" => "open",
"symbol" => {
request.insert("sub".into(), json!(words.first().map_or("list", |w| w.as_str())));
"symbol"
}
"placeholder" => "placeholder",
"hold" => "hold",
"ui" => "ui",
"story" | "cue" | "record" => {
let sub = words.first().map_or(
Expand Down Expand Up @@ -632,8 +669,10 @@ pub fn cli(args: &[String]) -> i32 {
}
};
let patient = matches!(request["cmd"].as_str(), Some("wait" | "import" | "frame"))
|| (matches!(request["cmd"].as_str(), Some("export" | "score" | "scene" | "record"))
&& request["wait"] == json!(true));
|| (matches!(
request["cmd"].as_str(),
Some("export" | "score" | "scene" | "record" | "symbol")
) && request["wait"] == json!(true));
let socket = match choose(project.as_deref()) {
Ok(socket) => socket,
Err(e) => {
Expand Down Expand Up @@ -694,4 +733,30 @@ mod tests {
assert!(build(&args("move --clip x")).is_err());
assert!(build(&args("explode")).is_err());
}

#[test]
fn builds_symbol_requests() {
let r = build(&args("symbol make --clip 3,4 --name Intro --keep")).unwrap();
assert_eq!(
r,
json!({"cmd": "symbol", "sub": "make", "clips": [3, 4], "name": "Intro", "keep": true})
);
let r = build(&args("symbol fill --media 9 --label Logo --use logo.png --wait")).unwrap();
assert_eq!(
r,
json!({"cmd": "symbol", "sub": "fill", "media": 9, "label": "Logo", "use": "logo.png", "wait": true})
);
assert_eq!(build(&args("symbol")).unwrap()["sub"], json!("list"));
let r = build(&args("placeholder --clip 5 --none")).unwrap();
assert_eq!(r, json!({"cmd": "placeholder", "clips": [5], "none": true}));
let r = build(&args("symbol rename --name Intro --new Opener")).unwrap();
assert_eq!(
r,
json!({"cmd": "symbol", "sub": "rename", "name": "Intro", "new": "Opener"})
);
let r = build(&args("symbol fit --clip 4 --as canvas")).unwrap();
assert_eq!(r, json!({"cmd": "symbol", "sub": "fit", "clips": [4], "as": "canvas"}));
let r = build(&args("symbol update --all")).unwrap();
assert_eq!(r, json!({"cmd": "symbol", "sub": "update", "all": true}));
}
}
Loading
Loading