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.2.0"
version = "0.3.0"
edition = "2024"
rust-version = "1.90"
license = "MIT"
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ make install-app # Splice.app in /Applications
length you choose. A slow zoom can run across the whole clip.
- **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
remains stays where it was.
- **Mask:** circle or box, with size, position, feather and invert.
- **Sound:** volume.

Expand Down Expand Up @@ -233,6 +235,20 @@ while you cut it.
- They are kept in the project file, out of the media panel.
- **Cues** (C) mark moments on the ruler, such as "VO starts" or "music
lifts".
- **Bible** (the second page of Story view): the characters, places and
things the movie keeps coming back to, and its look. Each has pictures
(drop them on the card; the first is its face) and the words that must
stay the same in every shot.
- **Cast**: each beat card shows who and what is in it as faces. Click **+
Cast** to choose from the bible.
- **Takes**: footage tried for a beat, generated or shot. Each remembers how
it was made (model, prompt, references). Click a take on the beat card to
lay it in. A take still being generated pulses, and the story lane says so.
- **Brief** copies the chosen beat's brief, a prompt to start from. It
carries the beat's story, framing, cast, look, length and shape.
`splice ctl story brief` also writes out the references and the frames
around the beat, for an assistant generating footage. Seedance through
Higgsfield works well; see the skill.
- **Shot list** copies the plan as text.
- Keys: B adds a beat, C marks a cue.

Expand Down
73 changes: 62 additions & 11 deletions crates/splice-app/src/ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Titles, colours and looks
--key green|blue|#rrggbb|none [--similarity F] [--blend F] [--despill/--no-despill]
--mask ellipse|rect|none [--mask-x F --mask-y F --mask-width F --mask-height F]
[--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)
C is #rrggbb, #rrggbbaa or a name: white, black, paper, ink, vermilion, gold, green, blue, clear.

Music (a score: instruments and notes, played by General MIDI sounds)
Expand Down Expand Up @@ -129,9 +131,32 @@ Story (planning: beats end to end along the timeline, and cues)
story shot add --beat B [--image FILE] [--note TEXT]
a reference shot: what it should look like (a picture, a frame of a video)
story shot set --shot S --note TEXT | story shot move --shot S --index I | story shot remove --shot S
story cast --beat B [--add ENTRY] [--remove ENTRY]
who and what from the bible is in a beat
story brief --beat B [--dir DIR]
everything to make a beat's footage: what happens, what to capture, its
cast's words and pictures, the look, length and shape, the frames just
before and after (written to DIR), and an `outline` prompt to start from
story take pending --beat B [--tool T] [--model M] [--mode X] [--prompt P] [--refs a,b] [--job J]
a take being made (generated): shows on the beat while it runs
story take land --take T --video FILE [--seam] [--keep]
story take add --beat B --video FILE [origin as above] [--seam] [--keep]
footage for a take; it is laid into the beat unless --keep (and a beat
with nothing laid in takes it anyway). --seam skips an extension's
first three frames, which repeat what it extends. The reply's `take`
says if it was chosen, and if it is short of or longer than the beat
story take fail --take T a generation that failed: its pending take goes
story take choose --take T | story take remove --take T
cue add [--at T] --label TEXT | cue set --cue C [--at T] [--label TEXT] | cue remove --cue C
view edit|story switch the window between Edit view and the Story board

The bible (who and what the movie keeps coming back to, and its look)
bible read entries: kind, name, words, pictures, the beats each is in
bible add --kind character|place|thing|look --name N [--words W] [--image FILE]
bible set --entry E [--name N] [--words W]
bible picture --entry E --image FILE | --remove I (the first picture is the face briefs lead with)
bible remove --entry E E is an entry's id or name

Recording the screen
record sources the displays and windows that can be recorded
record start [--display N | --window ID|APP | --region X,Y,W,H] [--mic] [--clicks] [--for S]
Expand All @@ -155,7 +180,8 @@ Playing and the window
select [--clip C] select clips (none: deselect)
command <words> run a command from the command menu (zoom fit, versions)
save [--label TEXT] keep a version
export <file.mp4> [--draft|--high] [--wait]
export <file.mp4> [--draft|--high] [--from T --to T | --clip C | --beat B] [--wait]
the movie, or just a stretch of it (a clip's span, a beat)
say --text TEXT show one line in Splice's status bar

Talking with the person
Expand Down Expand Up @@ -399,11 +425,12 @@ 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" => {
| "--query" | "--crop" => {
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" => {
| "--mask-x" | "--mask-y" | "--mask-width" | "--mask-height" | "--enter-for" | "--exit-for"
| "--crop-left" | "--crop-top" | "--crop-right" | "--crop-bottom" => {
let raw = next(arg)?;
let v = match raw.parse::<f64>() {
Ok(v) => json!(v),
Expand All @@ -419,17 +446,22 @@ fn build(args: &[String]) -> Result<Value, String> {
"--no-bold" | "--no-italic" | "--no-shadow" | "--no-invert" | "--no-despill" => {
request.insert(arg[5..].into(), json!(false));
}
"--at" | "--to" | "--start" | "--end" | "--duration" => {
"--at" | "--to" | "--from" | "--start" | "--end" | "--duration" => {
let raw = next(arg)?;
request.insert(arg[2..].into(), time_value(arg, raw)?);
}
"--track" | "--fps" | "--version" | "--beat" | "--cue" | "--index" | "--display" | "--after" | "--shot" => {
"--track" | "--fps" | "--version" | "--beat" | "--cue" | "--index" | "--display" | "--after" | "--shot"
| "--take" => {
let n: u64 = next(arg)?.parse().map_err(|_| format!("{arg} expects a number"))?;
request.insert(arg[2..].into(), json!(n));
}
"--image" => {
"--image" | "--video" | "--dir" => {
let file = next(arg)?;
request.insert("image".into(), json!(absolute(file)));
request.insert(arg[2..].into(), json!(absolute(file)));
}
"--entry" => {
let e = next(arg)?;
request.insert("entry".into(), e.parse::<u64>().map_or(json!(e), |id| json!(id)));
}
"--window" => {
let w = next(arg)?;
Expand Down Expand Up @@ -459,8 +491,9 @@ fn build(args: &[String]) -> Result<Value, String> {
let m = next(arg)?;
request.insert(arg[2..].into(), m.parse::<u64>().map_or(json!(m), |id| json!(id)));
}
"--text" | "--label" | "--name" | "--new" | "--as" | "--aspect" | "--layer" | "--into" | "--remove"
| "--json" | "--add" | "--title" | "--story" | "--capture" | "--region" | "--note" => {
"--text" | "--label" | "--name" | "--new" | "--as" | "--kind" | "--words" | "--prompt" | "--model"
| "--mode" | "--tool" | "--refs" | "--job" | "--aspect" | "--layer" | "--into" | "--remove" | "--json"
| "--add" | "--title" | "--story" | "--capture" | "--region" | "--note" => {
request.insert(arg[2..].into(), json!(value(next(arg)?)?));
}
"--file" => {
Expand All @@ -484,7 +517,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" | "--keep" | "--none" | "--all" | "--reset" | "--clear" => {
"--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" => {
request.insert(arg[2..].into(), json!(true));
}
"--draft" | "--high" => {
Expand Down Expand Up @@ -574,6 +607,10 @@ fn build(args: &[String]) -> Result<Value, String> {
"symbol"
}
"placeholder" => "placeholder",
"bible" => {
request.insert("sub".into(), json!(words.first().map_or("read", |w| w.as_str())));
"bible"
}
"hold" => "hold",
"ui" => "ui",
"story" | "cue" | "record" => {
Expand All @@ -585,7 +622,7 @@ fn build(args: &[String]) -> Result<Value, String> {
|w| w.as_str(),
);
request.insert("sub".into(), json!(sub));
if let Some(action) = words.get(1).filter(|_| sub == "shot") {
if let Some(action) = words.get(1).filter(|_| sub == "shot" || sub == "take") {
request.insert("action".into(), json!(action));
}
command.as_str()
Expand Down Expand Up @@ -756,6 +793,20 @@ mod tests {
);
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("bible add --kind character --name Ada --words silver")).unwrap();
assert_eq!(
r,
json!({"cmd": "bible", "sub": "add", "kind": "character", "name": "Ada", "words": "silver"})
);
let r = build(&args("story take pending --beat 3 --model seedance_2_5 --job j1")).unwrap();
assert_eq!(
r,
json!({"cmd": "story", "sub": "take", "action": "pending", "beat": 3, "model": "seedance_2_5", "job": "j1"})
);
let r = build(&args("export out.mp4 --from 2 --to 5")).unwrap();
assert_eq!((r["from"].clone(), r["to"].clone()), (json!(2.0), json!(5.0)));
let r = build(&args("look --clip 2 --crop-left 0.25")).unwrap();
assert_eq!(r["crop_left"], json!(0.25));
let r = build(&args("symbol update --all")).unwrap();
assert_eq!(r, json!({"cmd": "symbol", "sub": "update", "all": true}));
}
Expand Down
55 changes: 51 additions & 4 deletions crates/splice-app/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod agent;
mod input;
mod inspector;
mod made;
mod plan;
mod record;
mod render;
mod reuse;
Expand Down Expand Up @@ -135,6 +136,11 @@ enum Overlay {
microphone: bool,
clicks: bool,
},
/// Who and what from the bible is in a beat.
Cast {
beat: splice_core::story::BeatId,
index: usize,
},
/// Naming a symbol: a new one, a renamed or copied one, or a use's copy.
SymbolName {
naming: reuse::Naming,
Expand Down Expand Up @@ -245,6 +251,12 @@ pub struct Editor {
shown_shots: HashMap<splice_core::story::BeatId, splice_core::story::ShotId>,
/// Takes to lay into a beat once they are imported.
fill_after_import: HashMap<PathBuf, splice_core::story::BeatId>,
/// Footage being imported for a take: what it becomes when it lands.
take_after_import: HashMap<PathBuf, plan::Landing>,
/// Which page of the Story view shows.
page: plan::Page,
/// The picture on show on each bible card.
shown_pictures: HashMap<splice_core::bible::EntryId, usize>,
/// The score or scene editor, when one is open.
workbench: Option<workbench::Bench>,
_ctl: Task<()>,
Expand Down Expand Up @@ -380,6 +392,9 @@ impl Editor {
story_focus: cx.focus_handle(),
recording: None,
fill_after_import: HashMap::new(),
take_after_import: HashMap::new(),
page: plan::Page::default(),
shown_pictures: HashMap::new(),
shown_shots: HashMap::new(),
_ctl: ctl,
_autosave: autosave,
Expand Down Expand Up @@ -599,6 +614,7 @@ impl Editor {
let mut errors = Vec::new();
let mut added: Vec<(Media, PathBuf)> = Vec::new();
let mut placed = Vec::new();
let mut takes: Vec<serde_json::Value> = Vec::new();
self.doc.edit(|project| {
let mut at = place;
for result in probed {
Expand All @@ -622,7 +638,16 @@ impl Editor {
content: None,
});
let media = project.media(id).cloned().expect("just added");
if let Some(beat) = self.fill_after_import.remove(&path) {
if let Some(landing) = self.take_after_import.remove(&path) {
// The take is made (or lands) in the same step as
// its import: one undo, and nothing if it fails.
if let Some(report) = landing.land(project, id) {
if let Some(clip) = report["clip"].as_u64() {
placed.push(ClipId(clip));
}
takes.push(report);
}
} else if let Some(beat) = self.fill_after_import.remove(&path) {
if let Some(clip) = project.fill_beat(beat, id) {
placed.push(clip);
}
Expand Down Expand Up @@ -653,13 +678,16 @@ impl Editor {
.map(|(m, _)| serde_json::json!({ "id": m.id.0, "name": m.name }))
.collect();
let clips: Vec<_> = placed.iter().map(|c| c.0).collect();
let answer = serde_json::json!({
let mut answer = serde_json::json!({
"ok": errors.is_empty(),
"media": media,
"clips": clips,
"errors": errors,
"revision": self.doc.revision,
});
if let Some(take) = takes.first() {
answer["take"] = take.clone();
}
let _ = reply.send(answer.to_string());
}
if !placed.is_empty() {
Expand Down Expand Up @@ -1340,7 +1368,7 @@ impl Editor {
path.set_extension("mp4");
}
this.update(cx, |editor, cx| {
if let Err(e) = editor.start_export(path, quality, true, cx) {
if let Err(e) = editor.start_export(path, quality, true, None, cx) {
editor.say_error(e);
cx.notify();
}
Expand All @@ -1357,12 +1385,18 @@ impl Editor {
output: PathBuf,
quality: Quality,
reveal: bool,
range: Option<(Time, Time)>,
cx: &mut Context<Self>,
) -> Result<(), String> {
if let Some(waiting) = self.unrendered_in_use() {
return Err(format!("Cannot export yet: {waiting}"));
}
let plan = match export::plan(&self.doc.project, &self.resolver(), &output, quality) {
// Just a stretch: cut from the movie as drawn, so it matches.
let planned = match range {
Some((a, b)) => export::plan_range(&self.doc.project, &self.resolver(), &output, quality, a, b),
None => export::plan(&self.doc.project, &self.resolver(), &output, quality),
};
let plan = match planned {
Ok(plan) => plan,
Err(e) => return Err(format!("Cannot export: {e}")),
};
Expand Down Expand Up @@ -1512,6 +1546,9 @@ impl Editor {
let key = event.keystroke.key.as_str();
let typed = event.keystroke.key_char.clone();
let command = event.keystroke.modifiers.platform || event.keystroke.modifiers.control;
// The cast sheet's rows, counted before the overlay is borrowed.
// Its entries, then a new character, place and thing.
let castable = self.castable().len() + 2;
match &mut self.overlay {
Overlay::None => return,
Overlay::Palette { query, index } => match key {
Expand Down Expand Up @@ -1661,6 +1698,16 @@ impl Editor {
}
_ => {}
},
Overlay::Cast { beat, index } => match key {
"escape" => return self.close_overlay(window, cx),
"up" => *index = index.saturating_sub(1),
"down" => *index = (*index + 1).min(castable),
"enter" | "space" => {
let (beat, index) = (*beat, *index);
return self.cast_pick(beat, index, window, cx);
}
_ => {}
},
Overlay::SymbolMenu { name, index } => match key {
"escape" => return self.close_overlay(window, cx),
"up" => *index = index.saturating_sub(1),
Expand Down
Loading
Loading