From 705f89230a0d3c424fdea58a1f42ab4c54c44d52 Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Wed, 21 Oct 2026 12:00:22 -0700 Subject: [PATCH] Symbols: reusable pieces with placeholders; a limiter; mute and solo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbols are pieces of a movie kept for reuse in any project: intros, lower thirds, logo stings. Select clips and choose Make Symbol (⌥⌘K); they go into your symbols and become one use of it on the timeline. Each use fills the symbol's placeholders (words, a colour, a picture or a sound) for itself, renders like a scene, and can be stretched through the symbol's hold, fitted to another canvas shape, updated when the symbol is published again (keeping its fills), or broken apart. - Symbols live as .splice files in your symbols folder, each with its own identity, and publish only on save. Uses track newer versions, are marked on the timeline, and update one at a time or all at once. - The Symbols shelf shows cropped posters; a card's menu edits, renames, duplicates, reveals or trashes a symbol. - Renders of uses stay in the cache; saving collects unused bytes. - Export renders a timeline as a see-through ProRes layer. - `splice ctl symbol …`, `placeholder` and `hold` drive all of it. Also: playback and export hold peaks under full scale with a limiter, and in the score editor mute and solo no longer cancel out into silence. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 46 + crates/splice-app/src/actions.rs | 15 + crates/splice-app/src/audio.rs | 75 +- crates/splice-app/src/cli.rs | 1 + crates/splice-app/src/ctl.rs | 81 +- crates/splice-app/src/editor.rs | 78 +- crates/splice-app/src/editor/agent.rs | 12 + crates/splice-app/src/editor/inspector.rs | 213 +++- crates/splice-app/src/editor/made.rs | 154 ++- crates/splice-app/src/editor/render.rs | 22 +- crates/splice-app/src/editor/reuse.rs | 1144 ++++++++++++++++++++ crates/splice-app/src/editor/reuse_ui.rs | 1162 +++++++++++++++++++++ crates/splice-app/src/editor/shelf.rs | 104 +- crates/splice-app/src/editor/stage.rs | 44 +- crates/splice-app/src/editor/story.rs | 2 +- crates/splice-app/src/editor/workbench.rs | 25 +- crates/splice-app/src/icons.rs | 10 + crates/splice-app/src/main.rs | 1 + crates/splice-app/src/session.rs | 4 + crates/splice-app/src/symbols.rs | 534 ++++++++++ crates/splice-app/src/timeline.rs | 8 + crates/splice-core/src/export.rs | 97 +- crates/splice-core/src/lib.rs | 1 + crates/splice-core/src/model.rs | 71 +- crates/splice-core/src/score.rs | 58 + crates/splice-core/src/store.rs | 42 + crates/splice-core/src/story.rs | 9 +- crates/splice-core/src/symbol.rs | 1089 +++++++++++++++++++ skill/splice/SKILL.md | 61 +- 31 files changed, 5055 insertions(+), 114 deletions(-) create mode 100644 crates/splice-app/src/editor/reuse.rs create mode 100644 crates/splice-app/src/editor/reuse_ui.rs create mode 100644 crates/splice-app/src/symbols.rs create mode 100644 crates/splice-core/src/symbol.rs diff --git a/Cargo.lock b/Cargo.lock index f4e9b75..2016123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5366,7 +5366,7 @@ dependencies = [ [[package]] name = "splice-app" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "blake3", @@ -5390,7 +5390,7 @@ dependencies = [ [[package]] name = "splice-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 273992d..76d318b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 2362102..fff49ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/splice-app/src/actions.rs b/crates/splice-app/src/actions.rs index 481721a..f509331 100644 --- a/crates/splice-app/src/actions.rs +++ b/crates/splice-app/src/actions.rs @@ -53,6 +53,9 @@ actions!( RecordScreen, PasteReference, NewScene, + MakeSymbol, + BreakApart, + ShowSymbols, ShowMedia, ShowTextStyles, ShowColours, @@ -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), @@ -186,6 +191,9 @@ pub fn menus() -> Vec { 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(), @@ -275,6 +283,13 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box)> { ("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)), diff --git a/crates/splice-app/src/audio.rs b/crates/splice-app/src/audio.rs index 3685afb..cdcc575 100644 --- a/crates/splice-app/src/audio.rs +++ b/crates/splice-app/src/audio.rs @@ -23,6 +23,48 @@ struct Mix { /// Timeline frame being played. position: u64, voices: Vec, + 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 { @@ -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 { @@ -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; } @@ -107,6 +153,7 @@ fn fill(mix: &Mutex, 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; @@ -121,6 +168,7 @@ fn fill(mix: &Mutex, 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 }); @@ -128,3 +176,20 @@ fn fill(mix: &Mutex, out: &mut [S], channels: usize, con } 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"); + } +} diff --git a/crates/splice-app/src/cli.rs b/crates/splice-app/src/cli.rs index 9d061b3..74e82d1 100644 --- a/crates/splice-app/src/cli.rs +++ b/crates/splice-app/src/cli.rs @@ -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); } diff --git a/crates/splice-app/src/ctl.rs b/crates/splice-app/src/ctl.rs index 6c0383e..ff7b375 100644 --- a/crates/splice-app/src/ctl.rs +++ b/crates/splice-app/src/ctl.rs @@ -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 @@ -424,12 +455,12 @@ fn build(args: &[String]) -> Result { clips.push(json!(id.trim().parse::().map_err(|_| "--clip expects clip ids")?)); } } - "--media" => { + "--media" | "--use" => { let m = next(arg)?; - request.insert("media".into(), m.parse::().map_or(json!(m), |id| json!(id))); + request.insert(arg[2..].into(), m.parse::().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" => { @@ -453,7 +484,7 @@ 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" => { + "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" => { request.insert(arg[2..].into(), json!(true)); } "--draft" | "--high" => { @@ -538,6 +569,12 @@ fn build(args: &[String]) -> Result { 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( @@ -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) => { @@ -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})); + } } diff --git a/crates/splice-app/src/editor.rs b/crates/splice-app/src/editor.rs index 06aa084..88eff76 100644 --- a/crates/splice-app/src/editor.rs +++ b/crates/splice-app/src/editor.rs @@ -35,6 +35,8 @@ mod inspector; mod made; mod record; mod render; +mod reuse; +mod reuse_ui; mod shelf; mod shots; mod stage; @@ -61,6 +63,8 @@ pub struct MediaDrag { duration: Time, /// A title or colour from the shelf, rather than media. generator: Option, + /// A symbol from the library, by name, rather than media. + symbol: Option, } struct DragChip(SharedString); @@ -131,6 +135,18 @@ enum Overlay { microphone: bool, clicks: bool, }, + /// Naming a symbol: a new one, a renamed or copied one, or a use's copy. + SymbolName { + naming: reuse::Naming, + text: String, + /// The name is selected: typing replaces it. + all: bool, + }, + /// What to do with a symbol in your symbols. + SymbolMenu { + name: String, + index: usize, + }, /// Choosing the instrument a score's part plays. Instruments { media: MediaId, @@ -212,6 +228,10 @@ pub struct Editor { scrolled_layers: bool, /// Scores and scenes: their renders, errors, studios. made: made::Made, + /// The symbol library, as last read. + symbols: reuse::Shelf, + /// The ctl request being handled asked to wait for a render. + made_wait: bool, /// Edit view or Story view. mode: story::Mode, /// The story beat chosen on the board or the lane. @@ -238,6 +258,7 @@ pub enum Shelf { Media, Text, Color, + Symbols, } impl Editor { @@ -274,7 +295,13 @@ impl Editor { let watch = cx.spawn(async move |this, cx| { loop { cx.background_executor().timer(Duration::from_millis(700)).await; - if this.update(cx, |editor, cx| editor.poll_scenes(cx)).is_err() { + if this + .update(cx, |editor, cx| { + editor.poll_scenes(cx); + editor.poll_symbols(cx); + }) + .is_err() + { break; } } @@ -344,6 +371,8 @@ impl Editor { card_width: render::CARD_MIN, scrolled_layers: false, made: made::Made::default(), + symbols: reuse::Shelf::default(), + made_wait: false, workbench: None, mode: story::Mode::Edit, beat: None, @@ -590,6 +619,7 @@ impl Editor { has_audio: probe.has_audio, source: path.clone(), recipe: None, + content: None, }); let media = project.media(id).cloned().expect("just added"); if let Some(beat) = self.fill_after_import.remove(&path) { @@ -1595,6 +1625,52 @@ impl Editor { } _ => {} }, + Overlay::SymbolName { naming, text, all } => match key { + "escape" => return self.close_overlay(window, cx), + "enter" => { + let (naming, name) = (naming.clone(), text.clone()); + return self.finish_naming(naming, name, window, cx); + } + "backspace" => { + if *all || command { + text.clear(); + } else { + text.pop(); + } + *all = false; + } + "a" if command => *all = true, + "v" if command => { + if let Some(pasted) = cx.read_from_clipboard().and_then(|c| c.text()) { + if *all { + text.clear(); + } + text.push_str(pasted.lines().next().unwrap_or("")); + *all = false; + } + } + "left" | "right" | "home" | "end" => *all = false, + _ if !command => { + if let Some(c) = typed.filter(|c| !c.chars().any(char::is_control)) { + if *all { + text.clear(); + } + text.push_str(&c); + *all = false; + } + } + _ => {} + }, + Overlay::SymbolMenu { name, index } => match key { + "escape" => return self.close_overlay(window, cx), + "up" => *index = index.saturating_sub(1), + "down" => *index = (*index + 1).min(reuse_ui::MENU.len() - 1), + "enter" => { + let (name, index) = (name.clone(), *index); + return self.symbol_menu_pick(&name, index, window, cx); + } + _ => {} + }, Overlay::Versions { list, index } => match key { "escape" => return self.close_overlay(window, cx), "up" => *index = index.saturating_sub(1), diff --git a/crates/splice-app/src/editor/agent.rs b/crates/splice-app/src/editor/agent.rs index 7456711..01d802e 100644 --- a/crates/splice-app/src/editor/agent.rs +++ b/crates/splice-app/src/editor/agent.rs @@ -155,6 +155,7 @@ impl Editor { "story" if request["sub"] == json!("shot") => return self.ctl_shot(request, reply, 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), "ui" | "click" | "hover" | "drag" | "scroll" | "key" | "type" => { return self.ctl_input(cmd, request, reply, window, cx); } @@ -750,6 +751,9 @@ impl Editor { "end": s(clip.end()), "duration": s(clip.duration), }); + if let Some(label) = &clip.placeholder { + value["placeholder"] = json!(label); + } match &clip.generator { None => { value["media"] = json!(clip.media.0); @@ -889,6 +893,14 @@ impl Editor { } } + /// The clips a request names, or none when it names none. + pub(super) fn clips_of(&self, request: &Value) -> Result, String> { + if request.get("clips").is_none() { + return Ok(Vec::new()); + } + self.clips(request) + } + pub(super) fn find_media(&self, spec: &Value) -> Result { let media = &self.doc.project.media; let found = match spec { diff --git a/crates/splice-app/src/editor/inspector.rs b/crates/splice-app/src/editor/inspector.rs index 3cd94f9..a581735 100644 --- a/crates/splice-app/src/editor/inspector.rs +++ b/crates/splice-app/src/editor/inspector.rs @@ -169,14 +169,31 @@ impl Field { } } -/// A title's words being typed, and the project before the typing began. +/// Words being typed in the inspector, and the project before the typing began. pub struct TitleEdit { pub clip: ClipId, + /// Which words of the clip. + pub what: Typing, pub before: Project, /// Byte offset of the caret. pub cursor: usize, /// Everything is selected: the next keystroke replaces it. pub all: bool, + /// Words typed that are not yet anywhere in the project (a hex colour). + pub scratch: String, +} + +/// What is being typed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Typing { + /// A title's words. + Title, + /// The words one use of a symbol puts in its placeholder. + Fill(String), + /// The name of the placeholder a clip is, in a symbol. + Label, + /// A colour for one use's placeholder, typed as hex. + Hex(String), } /// Swatches offered for text, boxes and colours: 0xRRGGBBAA. @@ -385,7 +402,7 @@ impl Editor { .on_click(cx.listener(move |this, _, _, cx| f(this, cx))) } - fn swatch( + pub(super) fn swatch( &self, id: impl Into, color: Option, @@ -508,7 +525,7 @@ impl Editor { ) } - fn section(&self, title: &str) -> gpui::Div { + pub(super) fn section(&self, title: &str) -> gpui::Div { div() .px(px(16.0)) .pt(px(14.0)) @@ -612,6 +629,7 @@ impl Editor { self.section("Shape") .child(div().flex().flex_wrap().gap(px(6.0)).children(chips)), ) + .when_some(self.hold_section(cx), |d, hold| d.child(hold)) .child( div() .px(px(16.0)) @@ -627,6 +645,9 @@ impl Editor { let id = clip.id; let project = &self.doc.project; let (glyph, kind, visual, audio, media_clip) = match project.source(&clip) { + Some(Source::Media(m)) if matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_))) => { + ("symbol", "Symbol", true, m.has_audio, false) + } Some(Source::Media(m)) if m.recipe.is_some() => match m.kind { MediaKind::Audio => ("score", "Score", false, true, false), _ => ("scene", "Scene", true, false, false), @@ -651,10 +672,23 @@ impl Editor { ); let mut sections: Vec = Vec::new(); - let made = project + let symbol = project .media(clip.media) .filter(|_| clip.generator.is_none()) + .and_then(|m| match &m.recipe { + Some(splice_core::model::Recipe::Symbol(s)) => Some((m.id, s.clone())), + _ => None, + }); + let made = project + .media(clip.media) + .filter(|_| clip.generator.is_none() && symbol.is_none()) .and_then(|m| m.recipe.as_ref().map(|r| (m.id, r.kind()))); + if let Some((media, symbol)) = &symbol { + sections.extend(self.symbol_sections(id, *media, symbol, window, cx)); + } + if self.editing_a_symbol() { + sections.push(self.placeholder_section(id, &clip, window, cx)); + } if let Some((media, kind)) = made { let (glyph, label, hint) = if kind == "score" { ( @@ -769,7 +803,7 @@ impl Editor { let editing = self .title_edit .as_ref() - .filter(|e| e.clip == id && self.text_focus.is_focused(window)); + .filter(|e| e.clip == id && e.what == Typing::Title && self.text_focus.is_focused(window)); let (cursor, all) = editing.map_or((title.text.len(), false), |e| (e.cursor.min(title.text.len()), e.all)); let focused = editing.is_some(); @@ -1287,43 +1321,182 @@ impl Editor { // Typing a title. pub(super) fn begin_title_edit(&mut self, id: ClipId, window: &mut Window, cx: &mut Context) { - if self.title_edit.as_ref().is_some_and(|e| e.clip == id) { + self.begin_inspector_typing(id, Typing::Title, window, cx); + } + + /// Starts typing `what` of clip `id`, everything selected. + pub(super) fn begin_inspector_typing( + &mut self, + id: ClipId, + what: Typing, + window: &mut Window, + cx: &mut Context, + ) { + if self.title_edit.as_ref().is_some_and(|e| e.clip == id && e.what == what) { window.focus(&self.text_focus); return; } self.end_title_edit(cx); - let len = match self.doc.project.clip(id).and_then(|c| c.generator.as_ref()) { - Some(Generator::Text(t)) => t.text.len(), - _ => return, + if self.doc.project.clip(id).is_none() || (what == Typing::Title && self.typed_text(id, &what).is_none()) { + return; + } + // A colour typed as hex starts from the one shown. + let scratch = match &what { + Typing::Hex(key) => self + .doc + .project + .clip(id) + .and_then(|c| self.doc.project.media(c.media)) + .and_then(|m| match &m.recipe { + Some(splice_core::model::Recipe::Symbol(s)) => s.placeholder(key).map(|p| s.shown(&p)), + _ => None, + }) + .map(|fill| match fill { + splice_core::symbol::Fill::Color { color } => format!("#{:06x}", color >> 8), + _ => String::new(), + }) + .unwrap_or_default(), + _ => String::new(), + }; + let len = match &what { + Typing::Hex(_) => scratch.len(), + _ => self.typed_text(id, &what).map_or(0, |t| t.len()), }; self.selection = vec![id]; self.layout.inspector_open = true; self.title_edit = Some(TitleEdit { clip: id, + what, before: self.doc.project.clone(), cursor: len, all: true, + scratch, }); window.focus(&self.text_focus); } + /// The words `what` of clip `id` show now. + pub(super) fn typed_text(&self, id: ClipId, what: &Typing) -> Option { + use splice_core::symbol::Fill; + let clip = self.doc.project.clip(id)?; + match what { + Typing::Title => match &clip.generator { + Some(Generator::Text(t)) => Some(t.text.clone()), + _ => None, + }, + Typing::Label => Some(clip.placeholder.as_ref().map(|t| t.label.clone()).unwrap_or_default()), + Typing::Hex(_) => self.title_edit.as_ref().map(|e| e.scratch.clone()), + Typing::Fill(key) => match &self.doc.project.media(clip.media)?.recipe { + Some(splice_core::model::Recipe::Symbol(symbol)) => match symbol.shown(&symbol.placeholder(key)?) { + Fill::Text { text } => Some(text), + _ => None, + }, + _ => None, + }, + } + } + /// Finishes typing: the words become one undo step. pub(super) fn end_title_edit(&mut self, cx: &mut Context) { + use splice_core::model::Recipe; if let Some(edit) = self.title_edit.take() { - self.doc.commit(edit.before); + match &edit.what { + Typing::Label => { + // Typed into the tag as it was; now applied properly (a + // name another placeholder has joins it, an empty one + // keeps the old name). + let typed = self.typed_text(edit.clip, &Typing::Label).unwrap_or_default(); + let was = edit.before.clip(edit.clip).and_then(|c| c.placeholder.clone()); + if let Some(clip) = self.doc.project.clip_mut(edit.clip) { + clip.placeholder = was; + } + self.doc.project.set_placeholder(edit.clip, Some(&typed)); + } + Typing::Fill(key) => { + // A fill typed back to what the symbol has is no fill. + let media = self.doc.project.clip(edit.clip).map(|c| c.media); + if let Some(media) = media + && let Some(Recipe::Symbol(mut symbol)) = + self.doc.project.media(media).and_then(|m| m.recipe.clone()) + && let Some(p) = symbol.placeholder(key) + { + let fill = symbol.shown(&p); + let _ = symbol.fill(&p.id, fill); + self.doc.project.set_recipe(media, Recipe::Symbol(symbol)); + } + } + Typing::Hex(key) => { + let media = self.doc.project.clip(edit.clip).map(|c| c.media); + match super::agent::parse_color(edit.scratch.trim()) { + Some(color) => { + if let Some(media) = media + && let Some(Recipe::Symbol(mut symbol)) = + self.doc.project.media(media).and_then(|m| m.recipe.clone()) + && symbol.fill(key, splice_core::symbol::Fill::Color { color }).is_ok() + { + self.doc.project.set_recipe(media, Recipe::Symbol(symbol)); + } + } + None if !edit.scratch.trim().is_empty() => { + self.say_error(format!("{} is not a colour: try #ff8800", edit.scratch.trim())); + } + None => {} + } + } + Typing::Title => {} + } + if self.doc.project != edit.before { + self.doc.commit(edit.before); + } self.after_edit(); + self.reconcile_made(cx); cx.notify(); } } fn with_title_text(&mut self, f: impl FnOnce(&mut String, &mut TitleEdit)) { let Some(edit) = &mut self.title_edit else { return }; - if let Some(Generator::Text(title)) = self.doc.project.clip_mut(edit.clip).and_then(|c| c.generator.as_mut()) { - f(&mut title.text, edit); + let project = &mut self.doc.project; + match edit.what.clone() { + Typing::Title => { + if let Some(Generator::Text(title)) = project.clip_mut(edit.clip).and_then(|c| c.generator.as_mut()) { + f(&mut title.text, edit); + } + } + Typing::Label => { + if let Some(tag) = project.clip_mut(edit.clip).and_then(|c| c.placeholder.as_mut()) { + f(&mut tag.label, edit); + } + } + Typing::Hex(_) => { + let mut scratch = std::mem::take(&mut edit.scratch); + f(&mut scratch, edit); + edit.scratch = scratch; + } + Typing::Fill(key) => { + let Some(media) = project.clip(edit.clip).map(|c| c.media) else { + return; + }; + let Some(m) = project.media.iter_mut().find(|m| m.id == media) else { + return; + }; + let Some(splice_core::model::Recipe::Symbol(symbol)) = &mut m.recipe else { + return; + }; + let Some(placeholder) = symbol.placeholder(&key) else { + return; + }; + if !symbol.fills.contains_key(&placeholder.id) { + symbol.fills.insert(placeholder.id.clone(), placeholder.default.clone()); + } + if let Some(splice_core::symbol::Fill::Text { text }) = symbol.fills.get_mut(&placeholder.id) { + f(text, edit); + } + } } } - fn type_text(&mut self, typed: &str, cx: &mut Context) { + pub(super) fn type_text(&mut self, typed: &str, cx: &mut Context) { self.with_title_text(|text, edit| { if edit.all { text.clear(); @@ -1334,10 +1507,11 @@ impl Editor { text.insert_str(at, typed); edit.cursor = at + typed.len(); }); + self.preview_typed_fill(cx); cx.notify(); } - fn title_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + pub(super) fn title_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let k = &event.keystroke; let (cmd, alt) = (k.modifiers.platform, k.modifiers.alt); let prev = |text: &str, at: usize| text[..at].char_indices().next_back().map_or(0, |(i, _)| i); @@ -1352,6 +1526,16 @@ impl Editor { self.end_title_edit(cx); window.focus(&self.focus); } + // One-line fields finish on Return; words take a new line. + "enter" + if self + .title_edit + .as_ref() + .is_some_and(|e| matches!(e.what, Typing::Label | Typing::Hex(_))) => + { + self.end_title_edit(cx); + window.focus(&self.focus); + } "enter" => self.type_text("\n", cx), "backspace" => self.with_title_text(|text, edit| { if edit.all { @@ -1398,6 +1582,7 @@ impl Editor { None => return, }, } + self.preview_typed_fill(cx); cx.stop_propagation(); cx.notify(); } diff --git a/crates/splice-app/src/editor/made.rs b/crates/splice-app/src/editor/made.rs index 9fee865..d46e031 100644 --- a/crates/splice-app/src/editor/made.rs +++ b/crates/splice-app/src/editor/made.rs @@ -4,7 +4,7 @@ //! rendering it, while the previous render keeps playing. use super::*; -use crate::{music, scenes}; +use crate::{music, scenes, symbols}; use splice_core::model::{Recipe, Scene}; use splice_core::score::Score; use std::sync::atomic::{AtomicU32, Ordering}; @@ -36,18 +36,24 @@ pub struct Made { pub seen: HashMap, } +/// Where a render of a symbol keeps the part of its frame it draws on. +fn content_file(render: &Path) -> PathBuf { + render.with_extension("content.json") +} + /// Engine names, so a new synth or painter renders afresh. pub fn engine(recipe: &Recipe) -> &'static str { match recipe { Recipe::Score(_) => music::ENGINE, Recipe::Scene(_) => scenes::ENGINE, + Recipe::Symbol(_) => symbols::ENGINE, } } pub fn ext(recipe: &Recipe) -> &'static str { match recipe { Recipe::Score(_) => "wav", - Recipe::Scene(_) => "mov", + Recipe::Scene(_) | Recipe::Symbol(_) => "mov", } } @@ -58,6 +64,11 @@ impl Editor { let (kind, width, height, audio) = match &recipe { Recipe::Score(_) => (MediaKind::Audio, 0, 0, true), Recipe::Scene(_) => (MediaKind::Video, settings.width, settings.height, false), + // A symbol keeps its own canvas, and fits into this one like a video. + Recipe::Symbol(symbol) => { + let own = symbol.project.settings; + (MediaKind::Video, own.width, own.height, symbol.has_audio()) + } }; Media { id: MediaId(0), @@ -71,6 +82,7 @@ impl Editor { has_audio: audio, source: PathBuf::new(), recipe: Some(recipe), + content: None, } } @@ -91,6 +103,18 @@ impl Editor { if let Recipe::Scene(scene) = &recipe { self.sync_scene_file(media.id, scene); } + // A use of a symbol is named by what it says, and sounds when + // anything in it (a fill too) does. + if let Recipe::Symbol(symbol) = &recipe { + let (title, audio) = (symbol.title(), symbol.has_audio()); + if media.name != title || media.has_audio != audio { + for m in self.doc.project.media.iter_mut().filter(|m| m.id == media.id) { + m.name = title.clone(); + m.has_audio = audio; + } + self.doc.touch(); + } + } let mut media = media; if media.hash != key || media.duration != duration { // Keep showing the old render until the new one is ready. @@ -99,10 +123,20 @@ impl Editor { self.library.borrow_render(&old, &key); } self.doc.project.set_render(media.id, &key, duration); - if matches!(recipe, Recipe::Scene(_)) { + let size = match &recipe { + Recipe::Scene(_) => Some((settings.width, settings.height)), + Recipe::Symbol(symbol) => { + let at = symbol.render_settings(settings); + Some((at.width, at.height)) + } + Recipe::Score(_) => None, + }; + if let Some((width, height)) = size { for m in self.doc.project.media.iter_mut().filter(|m| m.id == media.id) { - m.width = settings.width; - m.height = settings.height; + m.width = width; + m.height = height; + // Where it draws comes with the new render. + m.content = None; } } self.doc.touch(); @@ -151,6 +185,7 @@ impl Editor { assets.loading = true; } let settings = self.doc.project.settings; + let store = self.doc.path().to_path_buf(); let id = media.id; // Redraw a few times a second so its progress shows. let watched = key.clone(); @@ -178,6 +213,18 @@ impl Editor { Recipe::Scene(scene) => scenes::render(&settings, scene, &target, |p| { progress.store(p.to_bits(), Ordering::Relaxed); }), + Recipe::Symbol(symbol) => symbols::render(symbol, settings, &store, &target, |p| { + progress.store(p.to_bits(), Ordering::Relaxed); + }) + .map(|content| { + // Kept beside the render, for when it is found in the cache. + if let Some(c) = content { + let _ = std::fs::write( + content_file(&target), + serde_json::to_string(&c).unwrap_or_default(), + ); + } + }), } .map(|()| target) }) @@ -199,9 +246,21 @@ impl Editor { assets.path = Some(path.clone()); if let Some(media) = self.doc.project.media(id).cloned().filter(|_| current) { self.player.forget(id); - if !self.doc.store.has_media(key).unwrap_or(false) { + // A use of a symbol renders again from what the project + // keeps, so its render (large, and new with every fill) + // stays in the cache rather than the project file. + let symbol = matches!(media.recipe, Some(Recipe::Symbol(_))); + if !symbol && !self.doc.store.has_media(key).unwrap_or(false) { self.embed(&media, path.clone(), cx); } + if symbol { + let content = std::fs::read_to_string(content_file(&path)) + .ok() + .and_then(|t| serde_json::from_str::<[f32; 4]>(&t).ok()); + for m in self.doc.project.media.iter_mut().filter(|m| m.id == id) { + m.content = content; + } + } self.build_assets(media, path, cx); if self.player.is_playing() { self.audio.stop(); @@ -456,36 +515,7 @@ impl Editor { } }; // Answers now, or when the render of `id` is done with `wait`. - let answer = |this: &mut Self, id: MediaId, extra: Value| -> Option { - let mut reply_json = this.edited_with(extra); - reply_json["media"] = this.made_json(id); - let hash = this.doc.project.media(id).map(|m| m.hash.clone()).unwrap_or_default(); - if wait { - if let Some(render) = this.made.renders.get_mut(&hash) { - let (tx, rx) = std::sync::mpsc::channel::(); - render.waiters.push(tx); - let base = reply_json.clone(); - std::thread::spawn(move || { - if let Ok(done) = rx.recv() { - let done: Value = serde_json::from_str(&done).unwrap_or(json!({})); - let mut out = base; - out["ok"] = done["ok"].clone(); - if let Some(e) = done.get("error") { - out["error"] = e.clone(); - } - out["media"]["rendering"] = json!(null); - let _ = reply.send(out.to_string()); - } - }); - return None; - } - if let Some(e) = this.made.errors.get(&id) { - reply_json["ok"] = json!(false); - reply_json["error"] = json!(e); - } - } - Some(reply_json) - }; + let answer = |this: &mut Self, id: MediaId, extra: Value| this.answer_made(id, extra, wait, reply.clone()); match (cmd, sub) { ("score", "instruments") => Some(json!({ @@ -527,6 +557,9 @@ impl Editor { return None; }; let mut out = json!({ "ok": true, "media": self.made_json(media.id), "score": score }); + if let Some(why) = score.silence() { + out["warning"] = json!(why); + } if let Ok(c) = score.compile() { out["compiled"] = json!({ "seconds": c.seconds, @@ -560,13 +593,14 @@ impl Editor { } let id = media.id; let title = score.title.clone(); + let warning = score.silence(); self.edit(cx, |p| { p.set_recipe(id, Recipe::Score(score)); if let Some(m) = p.media.iter_mut().find(|m| m.id == id) { m.name = title; } }); - answer(self, id, json!({})) + answer(self, id, warning.map_or_else(|| json!({}), |w| json!({ "warning": w }))) } ("scene", "help") => Some(json!({ "ok": true, @@ -756,6 +790,12 @@ impl Editor { id.0 )); } + Some(Recipe::Symbol(_)) => { + out["how"] = serde_json::json!(format!( + "splice ctl symbol read --media {0}; fill a placeholder with splice ctl symbol fill --media {0} --label L --text WORDS --wait", + id.0 + )); + } None => {} } out @@ -768,6 +808,46 @@ impl Editor { } /// A score or scene as `read` shows it. + /// The reply to a change of made media: now, or with `wait` when its + /// render is done (`None` then; the reply goes to `reply` later). + pub(super) fn answer_made( + &mut self, + id: MediaId, + extra: serde_json::Value, + wait: bool, + reply: std::sync::mpsc::Sender, + ) -> Option { + use serde_json::{Value, json}; + let mut reply_json = self.edited_with(extra); + reply_json["media"] = self.made_json(id); + let hash = self.doc.project.media(id).map(|m| m.hash.clone()).unwrap_or_default(); + if wait { + if let Some(render) = self.made.renders.get_mut(&hash) { + let (tx, rx) = std::sync::mpsc::channel::(); + render.waiters.push(tx); + let base = reply_json.clone(); + std::thread::spawn(move || { + if let Ok(done) = rx.recv() { + let done: Value = serde_json::from_str(&done).unwrap_or(json!({})); + let mut out = base; + out["ok"] = done["ok"].clone(); + if let Some(e) = done.get("error") { + out["error"] = e.clone(); + } + out["media"]["rendering"] = json!(null); + let _ = reply.send(out.to_string()); + } + }); + return None; + } + if let Some(e) = self.made.errors.get(&id) { + reply_json["ok"] = json!(false); + reply_json["error"] = json!(e); + } + } + Some(reply_json) + } + pub(super) fn made_json(&self, id: MediaId) -> serde_json::Value { use serde_json::json; let Some(media) = self.doc.project.media(id) else { diff --git a/crates/splice-app/src/editor/render.rs b/crates/splice-app/src/editor/render.rs index 6cef1ea..af6a290 100644 --- a/crates/splice-app/src/editor/render.rs +++ b/crates/splice-app/src/editor/render.rs @@ -8,7 +8,7 @@ use gpui::{AnyView, App, BoxShadow, FontWeight, Hsla, Rgba, Stateful, Svg, hsla, pub const TITLEBAR: f32 = 40.0; /// The crust showing between panels; also the grab area of each divider. -const GAP: f32 = 8.0; +pub(super) const GAP: f32 = 8.0; const STATUS: f32 = 26.0; const BIN_MIN: f32 = 180.0; const TIMELINE_MIN: f32 = 170.0; @@ -325,6 +325,9 @@ impl Render for Editor { .on_action(cx.listener(|this, _: &ShowMedia, _, cx| this.show_shelf(Shelf::Media, cx))) .on_action(cx.listener(|this, _: &ShowTextStyles, _, cx| this.show_shelf(Shelf::Text, cx))) .on_action(cx.listener(|this, _: &ShowColours, _, cx| this.show_shelf(Shelf::Color, cx))) + .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(|_: &Minimize, window, _| window.minimize_window()) .on_action(|_: &ZoomWindow, window, _| window.zoom_window()) .on_action(|_: &ToggleFullScreen, window, _| window.toggle_fullscreen()) @@ -344,6 +347,7 @@ impl Render for Editor { .px(px(GAP)) .flex() .flex_col() + .when_some(self.symbol_banner(cx), |d, banner| d.child(banner)) .child( div() .flex_1() @@ -794,6 +798,9 @@ impl Editor { .project .media .iter() + // Uses of symbols live on the timeline and the Symbols shelf; each + // use is its own media, so listing them would repeat one symbol. + .filter(|m| !matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_)))) .map(|media| { let assets = self.library.get(&media.hash); let thumb = assets.and_then(|a| a.thumb.clone()); @@ -811,6 +818,7 @@ impl Editor { splice_core::model::IMAGE_DURATION }, generator: None, + symbol: None, }; let (kind_icon, badge) = match media.kind { MediaKind::Image => ("image", None), @@ -939,7 +947,7 @@ impl Editor { if count > 0 { items.append(&mut makes); } - let tabs = self.shelf_tabs(count, cx); + let tabs = self.shelf_tabs(count, width < 250.0, cx); let action = self.shelf_header_action(cx); let on_media = self.shelf == Shelf::Media; let shelf = (!on_media).then(|| self.render_shelf(cx)); @@ -1293,6 +1301,7 @@ impl Editor { peaks: a.and_then(|a| a.peaks.clone()), size: (m.width, m.height), offline: a.is_some_and(|a| a.path.is_none() && !a.loading), + outdated: matches!(&m.recipe, Some(splice_core::model::Recipe::Symbol(s)) if self.has_newer(s)), }, ) }) @@ -1456,6 +1465,12 @@ impl Editor { this.hover_drop(event.event.position, 3 * SECOND, cx); })) .on_drop(cx.listener(|this, drag: &MediaDrag, _, cx| { + if let Some(name) = &drag.symbol { + if let Some(ghost) = this.ghost.take() { + this.add_symbol(name.to_string(), Some((ghost.start, ghost.track)), None, cx); + } + return cx.notify(); + } if let Some(ghost) = this.ghost.take() && let Some(clip) = this.edit(cx, |p| match &drag.generator { Some(generator) => { @@ -1643,6 +1658,9 @@ impl Editor { let content = match &self.overlay { Overlay::None => return None, Overlay::Record { .. } => panel().child(self.record_overlay(cx)), + Overlay::SymbolMenu { .. } | Overlay::SymbolName { .. } => { + panel().child(self.symbol_overlay(cx).unwrap_or_else(|| div().into_any_element())) + } Overlay::Palette { query, index } => { let matches = Self::palette_matches(query); let index = (*index).min(matches.len().saturating_sub(1)); diff --git a/crates/splice-app/src/editor/reuse.rs b/crates/splice-app/src/editor/reuse.rs new file mode 100644 index 0000000..462cb50 --- /dev/null +++ b/crates/splice-app/src/editor/reuse.rs @@ -0,0 +1,1144 @@ +//! Symbols in the editor: making one from clips or media, using one from your +//! symbols, filling its placeholders, keeping uses up to date, breaking one +//! apart, and managing the library. A use of a symbol is made media +//! (`Recipe::Symbol`) and renders like a scene. Each use has its own fills: +//! placing or duplicating a use makes another one (see `Project::insert_clip`). + +use super::*; +use crate::symbols; +use splice_core::model::Recipe; +use splice_core::symbol::{Dropped, Fill, Symbol}; +use std::collections::HashSet; + +/// What the editor knows about your symbols. +#[derive(Default)] +pub struct Shelf { + /// Newest first, each with what its file says once read. + pub list: Vec, + /// What each symbol's file said, by name, and when it was read. + infos: HashMap)>, + /// A read of the library is under way. + reading: bool, + /// Posters drawn or being drawn this session, by name and version, so a + /// failed one is not tried again and again. + drawing: HashSet<(String, String)>, + /// Typing a fill: which keystroke last scheduled a preview render. + pub(super) typed: u64, + /// Published versions already announced, so each is said once. + announced: HashSet, +} + +/// A use, measured against your symbols. +#[derive(Clone, Debug, PartialEq)] +pub enum UseState { + /// The same as the published symbol. + Current, + /// Your symbols have a later version of it. + Newer { name: String, drops: Vec }, + /// Your symbols have a version that did not come from this one (an + /// older one, or changed elsewhere). + Different { name: String, drops: Vec }, + /// Not in your symbols on this Mac. + Missing, +} + +/// What a naming sheet is for. +#[derive(Clone, Debug, PartialEq)] +pub enum Naming { + /// Make a symbol of these clips (or this media). + Make { + clips: Vec, + media: Option, + /// Titles among them to leave as they are, not placeholders. + plain: Vec, + }, + Rename(String), + Duplicate(String), + /// Keep a use's own copy in your symbols. + Keep(MediaId), +} + +impl Editor { + /// Rereads your symbols in the background: what is there, and inside + /// each file that changed. Then draws posters for new versions. + pub(super) fn poll_symbols(&mut self, cx: &mut Context) { + if self.symbols.reading { + return; + } + self.symbols.reading = true; + let known: HashMap)> = self.symbols.infos.clone(); + cx.spawn(async move |this, cx| { + let (list, infos) = cx + .background_spawn(async move { + let mut list = symbols::list(); + let mut infos = HashMap::new(); + for entry in &mut list { + let read = match known.get(&entry.name) { + Some(k) if k.0 == entry.modified => Some(k.clone()), + _ => symbols::info(&entry.name).ok().and_then(|info| { + let (_, project) = symbols::read(&entry.name).ok()?; + Some((entry.modified, info, Arc::new(project))) + }), + }; + if let Some(read) = read { + entry.info = Some(read.1.clone()); + infos.insert(entry.name.clone(), read); + } + } + (list, infos) + }) + .await; + this.update(cx, |editor, cx| { + editor.symbols.reading = false; + editor.symbols.infos = infos; + editor.symbols.list = list; + editor.draw_posters(cx); + editor.announce_updates(); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Says once when a symbol this movie uses has a newer version published, + /// since the uses themselves only show it when selected. + fn announce_updates(&mut self) { + let mut newer: Vec<(String, usize)> = Vec::new(); + for media in &self.doc.project.media { + let Some(Recipe::Symbol(symbol)) = &media.recipe else { + continue; + }; + if let UseState::Newer { name, .. } = self.use_state(symbol) + && let Some((_, info, _)) = self.library_of(symbol) + && !self.symbols.announced.contains(&info.version) + { + match newer.iter_mut().find(|(n, _)| *n == name) { + Some((_, count)) => *count += 1, + None => newer.push((name, 1)), + } + } + } + for media in &self.doc.project.media { + if let Some(Recipe::Symbol(symbol)) = &media.recipe + && let Some((_, info, _)) = self.library_of(symbol) + { + let version = info.version.clone(); + self.symbols.announced.insert(version); + } + } + if let Some((name, count)) = newer.first() { + let uses = if *count == 1 { + "its use".to_string() + } else { + format!("its {count} uses") + }; + self.say(format!( + "{name} has a newer version in your symbols: select {uses} to update" + )); + } + } + + fn draw_posters(&mut self, cx: &mut Context) { + for entry in self.symbols.list.clone() { + let Some(info) = &entry.info else { continue }; + if entry.poster().is_some() || !self.symbols.drawing.insert((entry.name.clone(), info.version.clone())) { + continue; + } + let Some((_, _, project)) = self.symbols.infos.get(&entry.name).cloned() else { + continue; + }; + let (name, version) = (entry.name.clone(), info.version.clone()); + cx.spawn(async move |this, cx| { + let task = name.clone(); + let result = cx + .background_spawn(async move { + symbols::draw_poster(&project, &symbols::file(&task), &symbols::poster(&task, &version)) + }) + .await; + if let Err(e) = result { + eprintln!("splice: no poster for {name}: {e}"); + } + this.update(cx, |_, cx| cx.notify()).ok(); + }) + .detach(); + } + } + + /// The library symbol a use came from: by identity, else by name. + fn library_of(&self, symbol: &Symbol) -> Option<(&symbols::Entry, &symbols::Info, &Arc)> { + let by = |e: &&symbols::Entry| match &e.info { + Some(info) if !symbol.id.is_empty() => info.id == symbol.id, + _ => symbol.id.is_empty() && e.name == symbol.name, + }; + let entry = self.symbols.list.iter().find(by)?; + let (_, info, project) = self.symbols.infos.get(&entry.name)?; + Some((entry, info, project)) + } + + /// The name a use's symbol has in your symbols now. + pub(super) fn library_name(&self, symbol: &Symbol) -> Option { + self.library_of(symbol).map(|(e, _, _)| e.name.clone()) + } + + /// Whether a newer version of a use's symbol is published: cheap, for + /// drawing every frame. + pub(super) fn has_newer(&self, symbol: &Symbol) -> bool { + self.library_of(symbol) + .is_some_and(|(_, info, _)| info.version != symbol.version && info.lineage.contains(&symbol.version)) + } + + /// A use against your symbols: current, newer, different or missing. + pub(super) fn use_state(&self, symbol: &Symbol) -> UseState { + let Some((entry, info, project)) = self.library_of(symbol) else { + return UseState::Missing; + }; + if info.version == symbol.version { + return UseState::Current; + } + let name = entry.name.clone(); + let drops = symbol.would_drop(project); + if info.lineage.contains(&symbol.version) { + UseState::Newer { name, drops } + } else { + UseState::Different { name, drops } + } + } + + /// The uses of `symbol` in this project. + pub(super) fn uses_of(&self, symbol: &Symbol) -> Vec { + self.doc + .project + .media + .iter() + .filter(|m| match &m.recipe { + Some(Recipe::Symbol(s)) => { + (!s.id.is_empty() && s.id == symbol.id) || (s.id.is_empty() && s.name == symbol.name) + } + _ => false, + }) + .map(|m| m.id) + .collect() + } + + /// The identity of the symbol this window edits, if it is one. + fn own_symbol(&self) -> Option { + if !self.editing_a_symbol() { + return None; + } + let name = self.doc.name(); + self.symbols + .infos + .get(&name) + .map(|(_, info, _)| info.id.clone()) + .or_else(|| symbols::info(&name).ok().map(|i| i.id)) + } + + /// A name for a new symbol: what its media or first title says, else + /// "Symbol", not taken in your symbols. + pub(super) fn suggest_name(&self, clips: &[ClipId], media: Option) -> String { + let base = media + .and_then(|m| self.doc.project.media(m)) + .map(|m| { + Path::new(&m.name) + .file_stem() + .map_or(m.name.clone(), |s| s.to_string_lossy().to_string()) + }) + .or_else(|| { + clips + .iter() + .filter_map(|id| self.doc.project.clip(*id)) + .find_map(|c| match &c.generator { + Some(Generator::Text(t)) => t.text.lines().next().map(|l| l.trim().chars().take(32).collect()), + _ => None, + }) + }) + .filter(|l: &String| !l.is_empty()) + .unwrap_or_else(|| "Symbol".into()); + let base = symbols::clean_name(&base).unwrap_or_else(|_| "Symbol".into()); + let mut name = base.clone(); + let mut n = 2; + while symbols::taken(&name) { + name = format!("{base} {n}"); + n += 1; + } + name + } + + /// Asks for a name, then makes the selected clips a symbol. + pub(super) fn make_symbol_action(&mut self, _: &MakeSymbol, window: &mut Window, cx: &mut Context) { + let clips = self.selection.clone(); + if clips.is_empty() { + self.say("Select the clips to make into a symbol"); + return cx.notify(); + } + let text = self.suggest_name(&clips, None); + self.open_naming( + Naming::Make { + clips, + media: None, + plain: Vec::new(), + }, + text, + window, + cx, + ); + } + + /// Opens the naming sheet for `naming`, the field holding `text`. + pub(super) fn open_naming(&mut self, naming: Naming, text: String, window: &mut Window, cx: &mut Context) { + self.overlay = Overlay::SymbolName { + naming, + text, + all: true, + }; + window.focus(&self.palette_focus); + cx.notify(); + } + + /// Does what a naming sheet was for, with the name typed. + pub(super) fn finish_naming(&mut self, naming: Naming, name: String, window: &mut Window, cx: &mut Context) { + let name = match symbols::clean_name(&name) { + Ok(n) => n, + Err(e) => { + self.say_error(e.to_string()); + return cx.notify(); + } + }; + self.close_overlay(window, cx); + match naming { + Naming::Make { clips, media, plain } => { + self.make_symbol(Some(&name), clips, &plain, media, false, None, cx) + } + Naming::Rename(from) => self.library_op(LibraryOp::Rename(from, name), None, cx), + Naming::Duplicate(from) => self.library_op(LibraryOp::Duplicate(from, name), None, cx), + Naming::Keep(media) => self.keep_symbol(media, &name, None, cx), + } + } + + /// Turns clips into a symbol in your symbols. Unless `keep`, they are + /// replaced on the timeline by one use of it. With one piece of media and + /// no clips, the symbol is that media. + #[allow(clippy::too_many_arguments)] + pub(super) fn make_symbol( + &mut self, + name: Option<&str>, + clips: Vec, + plain: &[ClipId], + media: Option, + keep: bool, + reply: Option, + cx: &mut Context, + ) { + let piece = match media { + Some(id) => { + let Some(m) = self.doc.project.media(id).cloned() else { + return self.fail(&reply, "no such media".into(), cx); + }; + if matches!(m.recipe, Some(Recipe::Symbol(_))) { + return self.fail(&reply, format!("{} is already a use of a symbol", m.name), cx); + } + let mut piece = Project::default(); + piece.settings = self.doc.project.settings; + let id = piece.add_media(m); + piece.insert_clip(id, 0, 0); + Some(piece) + } + None => self.doc.project.extract_with(&clips, plain), + }; + let Some(piece) = piece.filter(|p| p.duration() > 0) else { + return self.fail(&reply, "Select the clips to make into a symbol".into(), cx); + }; + let name = match name { + Some(n) => match symbols::clean_name(n) { + Ok(n) => n, + Err(e) => return self.fail(&reply, e.to_string(), cx), + }, + None => self.suggest_name(&clips, media), + }; + if symbols::taken(&name) { + return self.fail( + &reply, + format!("There is already a symbol called {name:?}; pick another name"), + cx, + ); + } + let from = self.doc.path().to_path_buf(); + self.working += 1; + self.say(format!("Making the symbol {name}…")); + let saved = piece.clone(); + let task_name = name.clone(); + let wait = self.made_wait; + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { symbols::save(&task_name, &saved, &from, None) }) + .await; + this.update(cx, |editor, cx| { + editor.working -= 1; + editor.poll_symbols(cx); + let id = match result { + Ok(id) => id, + Err(e) => return editor.fail(&reply, format!("Could not make the symbol: {e}"), cx), + }; + if keep || media.is_some() { + editor.say(format!("{name} is in your symbols")); + answer(&reply, serde_json::json!({ "ok": true, "symbol": name })); + return cx.notify(); + } + let symbol = Symbol::new(&id, &name, piece); + let duration = symbol.project.duration(); + let made = editor.made_media(name.clone(), Recipe::Symbol(symbol)); + let mut clip = None; + let media = editor.edit(cx, |p| { + let id = p.add_media(made); + clip = p.replace_with(&clips, id, duration); + id + }); + editor.selection = clip.into_iter().collect(); + editor.layout.inspector_open = true; + editor.say(format!( + "{name} is in your symbols, and this is one use of it. Undo puts the clips back; {name} stays in your symbols" + )); + if let Some(reply) = reply { + let extra = serde_json::json!({ "symbol": name, "clip": clip.map(|c| c.0) }); + if let Some(out) = editor.answer_made(media, extra, wait, reply.clone()) { + let _ = reply.send(out.to_string()); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Brings the library symbol `name` into the project and, with `place` + /// (else at the playhead, over what is there), onto the timeline. + pub(super) fn add_symbol( + &mut self, + name: String, + place: Option<(Time, usize)>, + reply: Option, + cx: &mut Context, + ) { + let into = self.doc.path().to_path_buf(); + let own = self.own_symbol(); + let wait = self.made_wait; + self.working += 1; + let task_name = name.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let (id, project) = symbols::read(&task_name)?; + symbols::copy_media(&project, &symbols::file(&task_name), &Store::open(&into)?)?; + anyhow::Ok((id, project)) + }) + .await; + this.update(cx, |editor, cx| { + editor.working -= 1; + let (id, project) = match result { + Ok(p) => p, + Err(e) => return editor.fail(&reply, format!("Could not use the symbol {name}: {e}"), cx), + }; + let symbol = Symbol::new(&id, &name, project); + // A symbol cannot hold itself, at any depth. + if own.as_deref().is_some_and(|own| symbol.uses(own)) { + let here = editor.doc.name(); + return editor.fail( + &reply, + format!("{name} cannot go inside {here}: it would hold itself"), + cx, + ); + } + let duration = symbol.project.duration(); + let made = editor.made_media(name.clone(), Recipe::Symbol(symbol)); + let at = place.map_or(editor.player.position, |(t, _)| t); + let track = place.map_or_else(|| editor.layer_above(at, duration), |(_, track)| track); + let mut clip = None; + let id = editor.edit(cx, |p| { + let id = p.add_media(made); + clip = p.insert_clip(id, track, at); + id + }); + editor.selection = clip.into_iter().collect(); + editor.layout.inspector_open = true; + if let Some(reply) = reply { + let extra = serde_json::json!({ "clip": clip.map(|c| c.0) }); + if let Some(out) = editor.answer_made(id, extra, wait, reply.clone()) { + let _ = reply.send(out.to_string()); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Changes one use's symbol with `f`; it re-renders. + pub(super) fn change_use( + &mut self, + media: MediaId, + cx: &mut Context, + f: impl FnOnce(&mut Symbol) -> anyhow::Result<()>, + ) -> Result<(), String> { + let Some(Recipe::Symbol(mut symbol)) = self.doc.project.media(media).and_then(|m| m.recipe.clone()) else { + return Err("that is not a use of a symbol".into()); + }; + f(&mut symbol).map_err(|e| e.to_string())?; + self.edit(cx, |p| p.set_recipe(media, Recipe::Symbol(symbol))); + Ok(()) + } + + /// Puts `fill` in one use's placeholder `key` (its label or id). + pub(super) fn fill_symbol( + &mut self, + media: MediaId, + key: &str, + fill: Fill, + cx: &mut Context, + ) -> Result<(), String> { + self.change_use(media, cx, |s| s.fill(key, fill)) + } + + /// Takes the published version for these uses (all of one symbol), + /// keeping fills that still fit; says which do not. + pub(super) fn update_uses(&mut self, uses: Vec, reply: Option, cx: &mut Context) { + let Some(Recipe::Symbol(first)) = uses + .first() + .and_then(|m| self.doc.project.media(*m)) + .and_then(|m| m.recipe.clone()) + else { + return self.fail(&reply, "nothing to update".into(), cx); + }; + let Some((entry, _, _)) = self.library_of(&first) else { + return self.fail(&reply, format!("{} is not in your symbols", first.name), cx); + }; + let name = entry.name.clone(); + let into = self.doc.path().to_path_buf(); + self.working += 1; + cx.spawn(async move |this, cx| { + let task = name.clone(); + let result = cx + .background_spawn(async move { + let (_, project) = symbols::read(&task)?; + symbols::copy_media(&project, &symbols::file(&task), &Store::open(&into)?)?; + anyhow::Ok(project) + }) + .await; + this.update(cx, |editor, cx| { + editor.working -= 1; + let project = match result { + Ok(p) => p, + Err(e) => return editor.fail(&reply, format!("Could not update: {e}"), cx), + }; + let mut dropped: Vec = Vec::new(); + editor.edit(cx, |p| { + for media in &uses { + let Some(Recipe::Symbol(mut symbol)) = p.media(*media).and_then(|m| m.recipe.clone()) else { + continue; + }; + dropped.extend(symbol.update(project.clone(), &name)); + p.set_recipe(*media, Recipe::Symbol(symbol)); + } + }); + let lost: Vec = dropped + .iter() + .map(|d| format!("{} ({})", shown(&d.fill), d.label)) + .collect(); + let places = match uses.len() { + 1 => String::new(), + n => format!(" in {n} places"), + }; + if lost.is_empty() { + editor.say(format!("Updated {name}{places}")); + } else { + editor.say_error(format!( + "Updated {name}{places}. These had no place left and were dropped (undo brings them back): {}", + lost.join(", ") + )); + } + if let Some(reply) = reply { + let mut out = editor.edited_with(serde_json::json!({ + "updated": uses.iter().map(|m| m.0).collect::>(), + "dropped": dropped, + })); + out["symbol"] = serde_json::json!(name); + let _ = reply.send(out.to_string()); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Puts a use's own copy into your symbols as `name`, keeping its identity + /// when that is free, so this use and others like it follow it again. + pub(super) fn keep_symbol(&mut self, media: MediaId, name: &str, reply: Option, cx: &mut Context) { + let Some(Recipe::Symbol(symbol)) = self.doc.project.media(media).and_then(|m| m.recipe.clone()) else { + return self.fail(&reply, "that is not a use of a symbol".into(), cx); + }; + if symbols::taken(name) { + return self.fail(&reply, format!("There is already a symbol called {name:?}"), cx); + } + let from = self.doc.path().to_path_buf(); + let (task, project) = (name.to_string(), (*symbol.project).clone()); + let id = (!symbol.id.is_empty() && self.library_of(&symbol).is_none()).then(|| symbol.id.clone()); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { symbols::save(&task, &project, &from, id.as_deref()) }) + .await; + this.update(cx, |editor, cx| { + editor.poll_symbols(cx); + match result { + Ok(_) => { + editor.say(format!("{} is in your symbols", symbol.name)); + answer(&reply, serde_json::json!({ "ok": true })); + } + Err(e) => editor.fail(&reply, format!("Could not keep it: {e}"), cx), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Puts a use's clips back on the timeline, as they are in it. + pub(super) fn break_apart(&mut self, clip: ClipId, cx: &mut Context) -> Vec { + let made = self.edit(cx, |p| p.break_apart(clip)); + if made.is_empty() { + self.say("Select a use of a symbol to break apart"); + } else { + self.selection = made.clone(); + self.say("Broke the symbol apart: its clips are yours to edit, and no longer follow the symbol"); + } + cx.notify(); + made + } + + pub(super) fn break_apart_action(&mut self, _: &BreakApart, _: &mut Window, cx: &mut Context) { + let first = self.selection.iter().copied().find(|id| { + self.doc + .project + .clip(*id) + .and_then(|c| self.doc.project.media(c.media)) + .is_some_and(|m| matches!(m.recipe, Some(Recipe::Symbol(_)))) + }); + match first { + Some(clip) => { + self.break_apart(clip, cx); + } + None => { + self.say("Select a use of a symbol to break apart"); + cx.notify(); + } + } + } + + /// Changes your symbols: rename, copy, move to the Trash. + pub(super) fn library_op(&mut self, op: LibraryOp, reply: Option, cx: &mut Context) { + let task = op.clone(); + self.working += 1; + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + match &task { + LibraryOp::Rename(from, to) => symbols::rename(from, to).map(|()| None), + LibraryOp::Duplicate(from, to) => symbols::duplicate(from, to).map(|()| None), + LibraryOp::Delete(name) => symbols::delete(name).map(Some), + } + }) + .await; + this.update(cx, |editor, cx| { + editor.working -= 1; + editor.poll_symbols(cx); + match result { + Ok(trashed) => { + let text = match &op { + LibraryOp::Rename(from, to) => format!("Renamed {from} to {to}; its uses follow it"), + LibraryOp::Duplicate(from, to) => format!("Copied {from} as {to}, a symbol of its own"), + LibraryOp::Delete(name) => { + format!("Moved {name} to the Trash; projects that use it keep their copy") + } + }; + editor.say(text); + answer(&reply, serde_json::json!({ "ok": true, "trashed": trashed })); + } + Err(e) => editor.fail(&reply, e.to_string(), cx), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub(super) fn edit_symbol(&mut self, name: &str) { + match symbols::open_window(name) { + Ok(()) => self.say(format!( + "Opened {name}. Save (⌘S) there to publish it; projects using it can then update" + )), + Err(e) => self.say_error(format!("Could not open {name}: {e}")), + } + } + + /// While a fill is typed, its use re-renders shortly after each pause, + /// so the picture keeps up with the words. + pub(super) fn preview_typed_fill(&mut self, cx: &mut Context) { + if !matches!( + self.title_edit.as_ref().map(|e| &e.what), + Some(super::inspector::Typing::Fill(_)) + ) { + return; + } + self.symbols.typed += 1; + let typed = self.symbols.typed; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_millis(450)).await; + this.update(cx, |editor, cx| { + if editor.symbols.typed == typed && editor.title_edit.is_some() { + editor.reconcile_made(cx); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + fn fail(&mut self, reply: &Option, e: String, cx: &mut Context) { + answer(reply, crate::ctl::fail(&e)); + self.say_error(e); + cx.notify(); + } + + /// A use's placeholders and state, as `symbol read` reports them. + fn symbol_json(&self, symbol: &Symbol) -> serde_json::Value { + use serde_json::json; + let state = self.use_state(symbol); + let library = match &state { + UseState::Current => json!({ "state": "current" }), + UseState::Newer { name, drops } => json!({ "state": "newer", "name": name, "would_drop": drops }), + UseState::Different { name, drops } => json!({ "state": "different", "name": name, "would_drop": drops }), + UseState::Missing => json!({ "state": "missing" }), + }; + let host = self.doc.project.settings; + json!({ + "id": symbol.id, + "name": symbol.name, + "version": symbol.version, + "size": format!("{}x{}", symbol.project.settings.width, symbol.project.settings.height), + "fit": if symbol.reflow { "canvas" } else { "own" }, + "shape_differs": symbol.shape_differs(host), + "duration": secs(symbol.project.duration()), + "library": library, + "update_available": matches!(state, UseState::Newer { .. } | UseState::Different { .. }), + "placeholders": symbol.placeholders().iter().map(|p| json!({ + "id": p.id, + "label": p.label, + "takes": p.takes, + "default": fill_json(&p.default), + "now": fill_json(&symbol.shown(p)), + "filled": symbol.fills.contains_key(&p.id), + })).collect::>(), + }) + } + + /// A library symbol as `symbol list` and `symbol read --name` report it. + fn library_json(&self, name: &str) -> Option { + use serde_json::json; + let (_, info, project) = self.symbols.infos.get(name)?; + let symbol = Symbol::new(&info.id, name, (**project).clone()); + Some(json!({ + "name": name, + "id": info.id, + "version": info.version, + "duration": secs(info.duration), + "size": format!("{}x{}", info.settings.width, info.settings.height), + "placeholders": symbol.placeholders().iter().map(|p| json!({ + "id": p.id, "label": p.label, "takes": p.takes, "default": fill_json(&p.default), + })).collect::>(), + "file": symbols::file(name), + })) + } + + /// Reads a library symbol now, for a request that needs it this moment. + fn read_now(&mut self, name: &str) { + if let Ok(info) = symbols::info(name) + && let Ok((_, project)) = symbols::read(name) + { + let modified = std::fs::metadata(symbols::file(name)) + .and_then(|m| m.modified()) + .unwrap_or_else(|_| SystemTime::now()); + self.symbols + .infos + .insert(name.to_string(), (modified, info, Arc::new(project))); + } + } + + pub(super) fn ctl_symbol( + &mut self, + cmd: &str, + request: &serde_json::Value, + reply: Reply, + cx: &mut Context, + ) -> Option { + use crate::ctl::fail; + use serde_json::{Value, json}; + let text = |key: &str| request.get(key).and_then(Value::as_str); + let flag = |key: &str| request.get(key).and_then(Value::as_bool) == Some(true); + let wait = flag("wait"); + self.made_wait = wait; + if cmd == "placeholder" { + if !self.editing_a_symbol() { + return Some(fail( + "placeholders are marked in a symbol's own window: run `symbol edit --name N`, then this with --project N", + )); + } + let clips = match self.clips_of(request) { + Ok(c) if !c.is_empty() => c, + Ok(_) => return Some(fail("give the clip with --clip C")), + Err(e) => return Some(fail(e)), + }; + let label = if flag("none") { + None + } else { + Some(text("label").unwrap_or("")) + }; + self.edit(cx, |p| { + for id in &clips { + p.set_placeholder(*id, label); + } + }); + let tags: Vec = clips + .iter() + .filter_map(|c| self.doc.project.clip(*c)) + .map(|c| json!({ "clip": c.id.0, "placeholder": c.placeholder })) + .collect(); + return Some(self.edited_with(json!({ + "marked": tags, + "note": "save (splice ctl save) to publish it to projects that use this symbol", + }))); + } + if cmd == "hold" { + if !self.editing_a_symbol() { + return Some(fail( + "a hold is set in a symbol's own window: `symbol edit --name N`, then --project N", + )); + } + let hold = if flag("clear") { + None + } else { + match (self.time(request, "start"), self.time(request, "end")) { + (Ok(Some(a)), Ok(Some(b))) if a < b => Some((a, b.min(self.doc.project.duration()))), + (Err(e), _) | (_, Err(e)) => return Some(fail(e)), + _ => return Some(fail("give --start T --end T (start before end), or --clear")), + } + }; + self.edit(cx, |p| p.hold = hold); + return Some(self.edited_with(json!({ + "hold": hold.map(|(a, b)| [secs(a), secs(b)]), + "note": "save to publish", + }))); + } + // The use a request names: --media, or --clip of it. + let used = |this: &Self| -> Result<(Media, Symbol), String> { + let id = match request.get("media") { + Some(m) => this.find_media(m)?, + None => { + let clips = this.clips_of(request)?; + let clip = clips + .first() + .and_then(|c| this.doc.project.clip(*c)) + .ok_or("give --media M or --clip C")?; + clip.media + } + }; + let media = this.doc.project.media(id).cloned().ok_or("no such media")?; + match &media.recipe { + Some(Recipe::Symbol(s)) => Ok((media.clone(), s.clone())), + _ => Err(format!("{} is not a use of a symbol", media.name)), + } + }; + match text("sub").unwrap_or("list") { + "list" => { + let names: Vec = symbols::list().into_iter().map(|e| e.name).collect(); + let list: Vec = names + .iter() + .map(|name| { + if !self.symbols.infos.contains_key(name) { + self.read_now(name); + } + self.library_json(name).unwrap_or_else(|| json!({ "name": name })) + }) + .collect(); + self.poll_symbols(cx); + Some(json!({ "ok": true, "folder": symbols::folder(), "symbols": list })) + } + "make" => { + let media = match request.get("media") { + Some(m) => match self.find_media(m) { + Ok(id) => Some(id), + Err(e) => return Some(fail(e)), + }, + None => None, + }; + let clips = match self.clips_of(request) { + Ok(c) if c.is_empty() && media.is_none() => self.selection.clone(), + Ok(c) => c, + Err(e) => return Some(fail(e)), + }; + self.make_symbol(text("name"), clips, &[], media, flag("keep"), Some(reply), cx); + None + } + "add" => { + let Some(name) = text("name") else { + return Some(fail("give the symbol's --name (see `symbol list`)")); + }; + let place = match self.time(request, "at") { + Ok(Some(t)) => Some((t, self.track(request).unwrap_or(0))), + Ok(None) => None, + Err(e) => return Some(fail(e)), + }; + self.add_symbol(name.to_string(), place, Some(reply), cx); + None + } + "read" => { + let by_name = text("name").filter(|_| request.get("media").is_none() && request.get("clips").is_none()); + if let Some(name) = by_name { + self.read_now(name); + return Some(match self.library_json(name) { + Some(v) => json!({ "ok": true, "symbol": v }), + None => fail(format!("there is no symbol called {name:?} in your symbols")), + }); + } + match used(self) { + Ok((media, symbol)) => Some(json!({ + "ok": true, + "media": self.made_json(media.id), + "symbol": self.symbol_json(&symbol), + })), + Err(e) => Some(fail(e)), + } + } + "fill" => { + let (media, _) = match used(self) { + Ok(m) => m, + Err(e) => return Some(fail(e)), + }; + let Some(label) = text("label") else { + return Some(fail("give the placeholder's --label (see `symbol read`)")); + }; + let changed = if flag("reset") { + self.change_use(media.id, cx, |s| s.unfill(label)) + } else { + let fill = if let Some(words) = text("text") { + Fill::Text { + text: words.to_string(), + } + } else if let Some(color) = text("color") { + match super::agent::parse_color(color) { + Some(c) => Fill::Color { color: c }, + None => return Some(fail(format!("{color} is not a colour"))), + } + } else if let Some(other) = request.get("use") { + let found = self + .find_media(other) + .ok() + .and_then(|id| self.doc.project.media(id).cloned()); + match found { + Some(m) if !matches!(m.recipe, Some(Recipe::Symbol(_))) => { + Fill::Media { media: Box::new(m) } + } + Some(m) => { + return Some(fail(format!("{} is a use of a symbol; fill with plain media", m.name))); + } + None => return Some(fail("--use names media in this project (import it first)")), + } + } else { + return Some(fail("give --text WORDS, --color C, --use MEDIA, or --reset")); + }; + self.fill_symbol(media.id, label, fill, cx) + }; + if let Err(e) = changed { + return Some(fail(e)); + } + self.answer_made(media.id, json!({}), wait, reply) + } + "length" => { + let (media, _) = match used(self) { + Ok(m) => m, + Err(e) => return Some(fail(e)), + }; + let Ok(Some(length)) = self.time(request, "duration") else { + return Some(fail("give --duration D, how long this use should run")); + }; + if let Err(e) = self.change_use(media.id, cx, |s| { + s.set_length(length); + Ok(()) + }) { + return Some(fail(e)); + } + self.answer_made(media.id, json!({}), wait, reply) + } + "fit" => { + let (media, _) = match used(self) { + Ok(m) => m, + Err(e) => return Some(fail(e)), + }; + let reflow = match text("as") { + Some("canvas") => true, + Some("own") => false, + _ => { + return Some(fail( + "give --as canvas (lay it out for this movie's shape) or --as own (keep its shape)", + )); + } + }; + if let Err(e) = self.change_use(media.id, cx, |s| { + s.reflow = reflow; + Ok(()) + }) { + return Some(fail(e)); + } + self.answer_made(media.id, json!({}), wait, reply) + } + "update" => { + let uses: Vec = if flag("all") { + self.doc + .project + .media + .iter() + .filter_map(|m| match &m.recipe { + Some(Recipe::Symbol(s)) + if matches!(self.use_state(s), UseState::Newer { .. } | UseState::Different { .. }) + && text("name").is_none_or(|n| n.eq_ignore_ascii_case(&s.name)) => + { + Some(m.id) + } + _ => None, + }) + .collect() + } else { + match used(self) { + Ok((m, _)) => vec![m.id], + Err(e) => return Some(fail(e)), + } + }; + if uses.is_empty() { + return Some(json!({ "ok": true, "updated": [], "note": "every use is up to date" })); + } + // One symbol at a time; the last answers. + let mut groups: Vec<(String, Vec)> = Vec::new(); + for m in uses { + let Some(Recipe::Symbol(s)) = self.doc.project.media(m).and_then(|m| m.recipe.as_ref()) else { + continue; + }; + let key = format!("{}|{}", s.id, s.name); + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, g)) => g.push(m), + None => groups.push((key, vec![m])), + } + } + let last = groups.len().saturating_sub(1); + for (i, (_, group)) in groups.into_iter().enumerate() { + self.update_uses(group, (i == last).then(|| reply.clone()), cx); + } + None + } + "edit" => { + let name = match text("name") { + Some(n) => n.to_string(), + None => match used(self) { + Ok((_, s)) => match self.library_of(&s) { + Some((entry, _, _)) => entry.name.clone(), + None => return Some(fail(format!("{} is not in your symbols on this Mac", s.name))), + }, + Err(_) => return Some(fail("give --name N, or --media M of a use")), + }, + }; + match symbols::open_window(&name) { + Ok(()) => Some(json!({ + "ok": true, + "opened": symbols::file(&name), + "project": name, + "note": "work in that window with --project; save there to publish", + })), + Err(e) => Some(fail(e.to_string())), + } + } + "keep" => { + let (media, symbol) = match used(self) { + Ok(m) => m, + Err(e) => return Some(fail(e)), + }; + let name = text("name").map_or(symbol.name.clone(), str::to_string); + self.keep_symbol(media.id, &name, Some(reply), cx); + None + } + "break" => { + let clips = match self.clips_of(request) { + Ok(c) if !c.is_empty() => c, + _ => return Some(fail("give the use with --clip C")), + }; + let made = self.break_apart(clips[0], cx); + if made.is_empty() { + return Some(fail("that clip is not a use of a symbol")); + } + Some(self.edited_with(json!({ "clips": made.iter().map(|c| c.0).collect::>() }))) + } + "rename" | "duplicate" | "delete" => { + let Some(name) = text("name") else { + return Some(fail("give the symbol's --name")); + }; + let op = match (text("sub"), text("new")) { + (Some("delete"), _) => LibraryOp::Delete(name.to_string()), + (Some("rename"), Some(new)) => LibraryOp::Rename(name.to_string(), new.to_string()), + (Some("duplicate"), Some(new)) => LibraryOp::Duplicate(name.to_string(), new.to_string()), + _ => return Some(fail("give the new name with --new NAME")), + }; + self.library_op(op, Some(reply), cx); + None + } + other => Some(fail(format!( + "unknown `symbol {other}`: use list, make, add, read, fill, length, fit, update, edit, keep, break, rename, duplicate or delete" + ))), + } + } +} + +/// A change to your symbols. +#[derive(Clone, Debug)] +pub enum LibraryOp { + Rename(String, String), + Duplicate(String, String), + Delete(String), +} + +pub type Reply = std::sync::mpsc::Sender; + +fn answer(reply: &Option, value: serde_json::Value) { + if let Some(reply) = reply { + let _ = reply.send(value.to_string()); + } +} + +/// A fill in words: what it is. +pub(super) fn shown(fill: &Fill) -> String { + match fill { + Fill::Text { text } => format!("“{}”", text.lines().next().unwrap_or("").trim()), + Fill::Color { color } => format!("#{:06x}", color >> 8), + Fill::Media { media } => media.name.clone(), + } +} + +fn fill_json(fill: &Fill) -> serde_json::Value { + use serde_json::json; + match fill { + Fill::Text { text } => json!({ "text": text }), + Fill::Color { color } => json!({ "color": format!("#{color:08x}") }), + Fill::Media { media } => json!({ "media": media.name, "kind": media.kind }), + } +} diff --git a/crates/splice-app/src/editor/reuse_ui.rs b/crates/splice-app/src/editor/reuse_ui.rs new file mode 100644 index 0000000..f8a25b4 --- /dev/null +++ b/crates/splice-app/src/editor/reuse_ui.rs @@ -0,0 +1,1162 @@ +//! How symbols look in the editor: a use's inspector (its placeholders first, +//! then its state against your symbols), marking placeholders in a symbol's +//! own window, the banner and see-through stage there, the shelf's cards, and +//! the sheets for naming symbols and managing them. + +use super::inspector::{SWATCHES, Typing}; +use super::render::{icon, tip}; +use super::reuse::{LibraryOp, Naming, UseState}; +use super::*; +use crate::symbols; +use gpui::{FontWeight, ObjectFit}; +use splice_core::model::{Clip, Recipe}; +use splice_core::symbol::{Fill, Symbol, Takes}; + +/// What the menu on a symbol's card offers. +pub const MENU: &[(&str, &str)] = &[ + ("Add at the playhead", "plus"), + ("Edit symbol", "symbol"), + ("Rename…", "text"), + ("Duplicate…", "copy"), + ("Show in Finder", "folder"), + ("Move to Trash", "trash"), +]; + +/// A small text button. +fn link(id: impl Into, label: impl Into) -> gpui::Stateful { + div() + .id(gpui::ElementId::Name(id.into())) + .text_size(px(10.5)) + .text_color(Theme::overlay()) + .cursor_pointer() + .hover(|s| s.text_color(Theme::text())) + .child(label.into()) +} + +/// A button the width of its row. +fn wide(id: &'static str, label: impl Into, primary: bool) -> gpui::Stateful { + div() + .id(id) + .h(px(28.0)) + .px(px(10.0)) + .flex_1() + .rounded(px(7.0)) + .flex() + .items_center() + .justify_center() + .gap(px(6.0)) + .cursor_pointer() + .text_size(px(11.5)) + .font_weight(FontWeight::SEMIBOLD) + .map(|d| { + if primary { + d.bg(Theme::text()) + .text_color(Theme::ground()) + .hover(|s| s.bg(Theme::subtext())) + } else { + d.border_1() + .border_color(Theme::edge()) + .text_color(Theme::subtext()) + .hover(|s| s.bg(Theme::lane()).text_color(Theme::text())) + } + }) + .child(label.into()) +} + +fn note(text: impl Into) -> gpui::Div { + div() + .text_size(px(11.0)) + .line_height(px(16.0)) + .text_color(Theme::overlay()) + .child(text.into()) +} + +impl Editor { + /// Whether the open project is a symbol in your symbols. + pub(super) fn editing_a_symbol(&self) -> bool { + self.doc.path().parent() == Some(symbols::folder().as_path()) + } + + /// A box of words that types in place: the caret shows while typing. + /// `hint` shows, dim, while it is empty. + fn words_box( + &mut self, + key: String, + id: ClipId, + what: Typing, + text: String, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let active = self + .title_edit + .as_ref() + .filter(|e| e.clip == id && e.what == what && self.text_focus.is_focused(window)) + .map(|e| (e.cursor.min(text.len()), e.all)); + let mut lines: Vec = Vec::new(); + let mut offset = 0; + for line in text.split('\n') { + let end = offset + line.len(); + let row = div().min_h(px(18.0)).flex().items_center().flex_wrap(); + let row = match active { + Some((cursor, false)) if cursor >= offset && cursor <= end => { + let (a, b) = line.split_at(cursor - offset); + row.child(a.to_string()) + .child(div().w(px(1.5)).h(px(16.0)).bg(Theme::accent())) + .child(b.to_string()) + } + _ => row.child(line.to_string()), + }; + lines.push(row.into_any_element()); + offset = end + 1; + } + let begin = what.clone(); + let field = div() + .id(SharedString::from(key)) + .min_h(px(32.0)) + .px(px(10.0)) + .py(px(6.0)) + .rounded(px(8.0)) + .bg(Theme::crust()) + .border_1() + .border_color(if active.is_some() { + Theme::surface2() + } else { + Theme::edge() + }) + .text_size(px(13.0)) + .line_height(px(18.0)) + .text_color(Theme::text()) + .cursor_text() + .when(active.is_some_and(|(_, all)| all), |d| d.bg(Theme::accent_soft())) + .children(lines) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.begin_inspector_typing(id, begin.clone(), window, cx); + cx.notify(); + }), + ); + // Only the box being typed in takes the keys. + match active { + Some(_) => field + .key_context("TextField") + .track_focus(&self.text_focus) + .on_key_down(cx.listener(Self::title_key)) + .on_action(cx.listener(|this, _: &TextSelectAll, _, cx| { + if let Some(edit) = &mut this.title_edit { + edit.all = true; + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &TextPaste, _, cx| { + if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) { + this.type_text(&text, cx); + } + })) + .on_action(cx.listener(|this, _: &TextDone, window, cx| { + this.end_title_edit(cx); + window.focus(&this.focus); + })) + .into_any_element(), + None => field.into_any_element(), + } + } + + /// For a use of a symbol: its placeholders to fill, then what it is and + /// how it stands against your symbols. + pub(super) fn symbol_sections( + &mut self, + id: ClipId, + media: MediaId, + symbol: &Symbol, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let mut out = Vec::new(); + let rendering = self + .doc + .project + .media(media) + .and_then(|m| self.made.renders.get(&m.hash)) + .map(|r| r.progress()); + + // Placeholders first: they are why a use is selected. + let placeholders = symbol.placeholders(); + let mut fields = self.section("Placeholders"); + if let Some(p) = rendering { + fields = fields.child( + div().h(px(2.0)).rounded_full().bg(Theme::lane()).child( + div() + .h_full() + .rounded_full() + .bg(Theme::accent()) + .w(gpui::relative(p.clamp(0.05, 1.0))), + ), + ); + } + if placeholders.is_empty() { + fields = fields.child(note( + "None yet. Edit the symbol and switch on “Each use can change this” for a title, colour, picture or sound.", + )); + } + for placeholder in placeholders { + let pid = placeholder.id.clone(); + let now = symbol.shown(&placeholder); + let changed = symbol.fills.contains_key(&pid); + let reset = pid.clone(); + let header = div() + .flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child(placeholder.label.clone()), + ) + .when(changed, |d| { + d.child( + link(format!("reset-{pid}"), "Reset") + .tooltip(tip("Back to what the symbol has", None)) + .on_click(cx.listener(move |this, _, _, cx| { + let _ = this.change_use(media, cx, |s| s.unfill(&reset)); + cx.notify(); + })), + ) + }); + let body: AnyElement = match &now { + Fill::Text { text } => { + let typing = self + .title_edit + .as_ref() + .is_some_and(|e| e.clip == id && e.what == Typing::Fill(pid.clone())); + let field = self.words_box( + format!("fill-{pid}"), + id, + Typing::Fill(pid.clone()), + text.clone(), + window, + cx, + ); + div() + .flex() + .flex_col() + .gap(px(3.0)) + .child(field) + .when(typing, |d| { + d.child( + div() + .text_size(px(10.0)) + .text_color(Theme::overlay()) + .child("The picture follows as you type · ⌘↩ or Esc to finish"), + ) + }) + .into_any_element() + } + Fill::Color { color } => { + let color = *color; + let swatches: Vec = SWATCHES + .iter() + .map(|&c| { + let pid = pid.clone(); + self.swatch( + format!("fill-{pid}-{c:08x}"), + Some(c), + c == color, + cx, + move |this, cx| { + let _ = this.fill_symbol(media, &pid, Fill::Color { color: c }, cx); + }, + ) + .into_any_element() + }) + .collect(); + let hex_typing = self + .title_edit + .as_ref() + .filter(|e| e.clip == id && e.what == Typing::Hex(pid.clone())) + .map(|e| e.scratch.clone()); + let hex = hex_typing.unwrap_or_else(|| format!("#{:06x}", color >> 8)); + div() + .flex() + .flex_col() + .gap(px(6.0)) + .child(div().flex().flex_wrap().gap(px(4.0)).children(swatches)) + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .size(px(22.0)) + .rounded(px(5.0)) + .border_1() + .border_color(Theme::edge()) + .bg(gpui::rgba(color)), + ) + .child(div().w(px(110.0)).font_family(MONO_FONT).child(self.words_box( + format!("hex-{pid}"), + id, + Typing::Hex(pid.clone()), + hex, + window, + cx, + ))) + .child(div().text_size(px(10.0)).text_color(Theme::overlay()).child("hex, ↩")), + ) + .into_any_element() + } + Fill::Media { media: shown } => self.media_slot(media, &placeholder.id, placeholder.takes, shown, cx), + }; + fields = fields.child( + div() + .flex() + .flex_col() + .gap(px(5.0)) + .pb(px(4.0)) + .child(header) + .child(body), + ); + } + out.push(fields.into_any_element()); + + // What it is, and how it stands against your symbols. + let state = self.use_state(symbol); + let uses = self.uses_of(symbol); + let stale: Vec = uses + .iter() + .copied() + .filter(|m| { + matches!(self.doc.project.media(*m).and_then(|m| m.recipe.as_ref()), + Some(Recipe::Symbol(s)) if matches!(self.use_state(s), UseState::Newer { .. } | UseState::Different { .. })) + }) + .collect(); + let (line, accent) = match &state { + UseState::Current => ( + match uses.len() { + 1 => "Edits to the symbol reach every use; what you fill in above is this use’s own.".to_string(), + n => format!("Used {n} times in this movie; what you fill in above is this use’s own."), + }, + false, + ), + UseState::Newer { drops, .. } | UseState::Different { drops, .. } => { + let what = if matches!(state, UseState::Newer { .. }) { + "There is a newer version in your symbols." + } else { + "Your symbols have a different version of this (changed elsewhere, or older)." + }; + let lost = if drops.is_empty() { + String::new() + } else { + let list: Vec = drops + .iter() + .map(|d| format!("{} ({})", super::reuse::shown(&d.fill), d.label)) + .collect(); + format!(" Updating would drop {}.", list.join(", ")) + }; + (format!("{what}{lost}"), true) + } + UseState::Missing => ( + "Not in your symbols on this Mac. This movie keeps its own copy, so it still plays; keep it to reuse it." + .to_string(), + true, + ), + }; + let mut row = div().flex().gap(px(6.0)); + match &state { + UseState::Newer { .. } | UseState::Different { .. } => { + let one = vec![media]; + row = row.child( + wide("symbol-update", "Update", true) + .tooltip(tip( + "Take the version in your symbols, keeping what you filled in", + None, + )) + .on_click(cx.listener(move |this, _, _, cx| { + this.update_uses(one.clone(), None, cx); + })), + ); + if stale.len() > 1 { + let all = stale.clone(); + row = row.child( + wide("symbol-update-all", format!("Update all {}", stale.len()), false).on_click(cx.listener( + move |this, _, _, cx| { + this.update_uses(all.clone(), None, cx); + }, + )), + ); + } + } + UseState::Missing => { + let name = symbol.name.clone(); + row = row.child(wide("symbol-keep", "Keep in my symbols…", true).on_click(cx.listener( + move |this, _, window, cx| { + let text = if symbols::taken(&name) { + format!("{name} 2") + } else { + name.clone() + }; + this.open_naming(Naming::Keep(media), text, window, cx); + }, + ))); + } + UseState::Current => {} + } + let host = self.doc.project.settings; + let own = symbol.project.settings; + let fit = symbol.shape_differs(host).then(|| { + let reflow = symbol.reflow; + div() + .flex() + .flex_col() + .gap(px(4.0)) + .child(note(format!( + "Made for {}×{}; this movie is {}×{}.", + own.width, own.height, host.width, host.height + ))) + .child( + div() + .flex() + .gap(px(4.0)) + .child(self.chip("fit-own", "Keep its shape", !reflow, cx, move |this, cx| { + let _ = this.change_use(media, cx, |s| { + s.reflow = false; + Ok(()) + }); + })) + .child(self.chip("fit-canvas", "Fit this movie", reflow, cx, move |this, cx| { + let _ = this.change_use(media, cx, |s| { + s.reflow = true; + Ok(()) + }); + })), + ) + }); + let in_library = !matches!(state, UseState::Missing); + let edit_name = match &state { + UseState::Newer { name, .. } | UseState::Different { name, .. } => name.clone(), + _ => symbol.name.clone(), + }; + let header = div() + .flex() + .items_center() + .gap(px(7.0)) + .child(icon("symbol", Theme::accent()).size(px(13.0))) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.5)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(Theme::text()) + .child(symbol.name.clone()), + ) + .when(in_library, |d| { + d.child( + link("symbol-edit", "Edit") + .tooltip(tip("Open the symbol in its own window", None)) + .on_click(cx.listener(move |this, _, _, cx| { + this.edit_symbol(&edit_name); + cx.notify(); + })), + ) + }) + .child( + link("symbol-break", "Break apart") + .tooltip(tip("Turn this use back into its clips, to edit freely", Some("⌥⌘B"))) + .on_click(cx.listener(move |this, _, _, cx| { + this.break_apart(id, cx); + })), + ); + out.push( + self.section("Symbol") + .child(header) + .child( + div() + .text_size(px(11.0)) + .line_height(px(16.0)) + .text_color(if accent { Theme::accent() } else { Theme::overlay() }) + .child(line), + ) + .when(!matches!(state, UseState::Current), |d| d.child(row)) + .when_some(fit, |d, fit| d.child(fit)) + .into_any_element(), + ); + out + } + + /// What can go in a picture or sound placeholder: this project's media of + /// that sort, the current one ringed, and Import. Media dropped on it goes + /// in too. + fn media_slot(&self, media: MediaId, pid: &str, takes: Takes, shown: &Media, cx: &mut Context) -> AnyElement { + let choices: Vec = self + .doc + .project + .media + .iter() + .filter(|m| takes.accepts(m.kind) && !matches!(m.recipe, Some(Recipe::Symbol(_)))) + .map(|m| { + let on = m.hash == shown.hash; + let thumb = self.library.get(&m.hash).and_then(|a| a.thumb.clone()); + let (choice, pid) = (m.clone(), pid.to_string()); + let sound = m.kind == MediaKind::Audio; + div() + .id(SharedString::from(format!("slot-{pid}-{}", m.id.0))) + .w(px(64.0)) + .h(px(40.0)) + .rounded(px(6.0)) + .overflow_hidden() + .bg(Theme::crust()) + .border_2() + .border_color(if on { Theme::accent() } else { Theme::edge() }) + .cursor_pointer() + .flex() + .items_center() + .justify_center() + .map(|d| match thumb.filter(|_| !sound) { + Some(image) => d.child(img(image).size_full().object_fit(ObjectFit::Cover)), + None => d.child(icon(if sound { "music" } else { "image" }, Theme::overlay()).size(px(14.0))), + }) + .tooltip(tip(m.name.clone(), None)) + .on_click(cx.listener(move |this, _, _, cx| { + if let Err(e) = this.fill_symbol( + media, + &pid, + Fill::Media { + media: Box::new(choice.clone()), + }, + cx, + ) { + this.say_error(e); + } + cx.notify(); + })) + .into_any_element() + }) + .collect(); + let import = div() + .id(SharedString::from(format!("slot-import-{pid}"))) + .w(px(64.0)) + .h(px(40.0)) + .rounded(px(6.0)) + .border_1() + .border_dashed() + .border_color(Theme::surface2()) + .flex() + .flex_col() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(Theme::lane())) + .child(icon("plus", Theme::overlay()).size(px(12.0))) + .child(div().text_size(px(9.5)).text_color(Theme::overlay()).child("Import")) + .on_click(cx.listener(|this, _, window, cx| this.import(&Import, window, cx))); + let size = (shown.width > 0 && takes == Takes::Picture).then(|| format!(" · {}×{}", shown.width, shown.height)); + let drop_pid = pid.to_string(); + div() + .id(SharedString::from(format!("slot-{pid}"))) + .flex() + .flex_col() + .gap(px(6.0)) + .p(px(7.0)) + .rounded(px(8.0)) + .border_1() + .border_color(Theme::edge()) + .drag_over::(|s, _, _, _| s.border_color(Theme::accent()).bg(Theme::surface0())) + .on_drop(cx.listener(move |this, drag: &MediaDrag, _, cx| { + cx.stop_propagation(); + if let Some(m) = this + .doc + .project + .media(drag.media) + .cloned() + .filter(|_| drag.generator.is_none() && drag.symbol.is_none()) + && let Err(e) = this.fill_symbol(media, &drop_pid, Fill::Media { media: Box::new(m) }, cx) + { + this.say_error(e); + } + cx.notify(); + })) + .child( + div() + .text_size(px(11.0)) + .text_color(Theme::muted()) + .truncate() + .child(format!("{}{}", shown.name, size.unwrap_or_default())), + ) + .child(div().flex().flex_wrap().gap(px(4.0)).children(choices).child(import)) + .child(note(match takes { + Takes::Sound => "A sound from this movie, or drag one here.", + _ => "A picture or video from this movie, or drag one here. A video shorter than the slot plays once.", + })) + .into_any_element() + } + + /// In a symbol's own window: whether this clip is a placeholder, and its name. + pub(super) fn placeholder_section( + &mut self, + id: ClipId, + clip: &Clip, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let on = clip.placeholder.is_some(); + let mut section = self.section("Placeholder").child(self.toggle( + "placeholder-toggle", + "Each use can change this", + on, + cx, + move |this, cx| { + let label = (!on).then_some(""); + this.edit(cx, |p| p.set_placeholder(id, label)); + }, + )); + if on { + let label = clip.placeholder.as_ref().map(|t| t.label.clone()).unwrap_or_default(); + section = section + .child( + div() + .text_size(px(11.0)) + .text_color(Theme::overlay()) + .child("Its name, as uses see it"), + ) + .child(self.words_box("placeholder-label".into(), id, Typing::Label, label, window, cx)); + } + section + .child(note( + "Give two clips the same name and they change together. Renaming keeps what uses filled in.", + )) + .into_any_element() + } + + /// In a symbol's own window, with nothing selected: the hold, the stretch + /// that a longer or shorter use stretches. + pub(super) fn hold_section(&self, cx: &mut Context) -> Option { + if !self.editing_a_symbol() { + return None; + } + let project = &self.doc.project; + let whole = project.duration(); + let fps = project.settings.fps; + let at = self.player.position.clamp(0, whole); + let (a, b) = project.hold.unwrap_or((0, whole)); + let shown = match project.hold { + Some(_) => format!("{} to {}", timecode(a, fps), timecode(b, fps)), + None => "All of it, evenly".into(), + }; + let set = |this: &mut Editor, cx: &mut Context, hold: Option<(Time, Time)>| { + this.edit(cx, |p| p.hold = hold.filter(|(a, b)| a < b)); + }; + Some( + self.section("Hold") + .child(note( + "A use made longer or shorter (drag its end) stretches this part; what comes before and after keeps its timing.", + )) + .child( + div() + .font_family(MONO_FONT) + .text_size(px(12.0)) + .text_color(Theme::text()) + .child(shown), + ) + .child( + div() + .flex() + .gap(px(4.0)) + .child(self.chip("hold-start", "Starts here", false, cx, move |this, cx| { + set(this, cx, Some((at, b.max(at + SECOND / 10)))); + })) + .child(self.chip("hold-end", "Ends here", false, cx, move |this, cx| { + set(this, cx, Some((a.min(at - SECOND / 10).max(0), at))); + })) + .child(self.chip("hold-all", "All", project.hold.is_none(), cx, move |this, cx| { + set(this, cx, None); + })), + ) + .into_any_element(), + ) + } + + /// Across the top of a symbol's own window: what it is, and whether its + /// changes are published yet. + pub(super) fn symbol_banner(&self, cx: &mut Context) -> Option { + if !self.editing_a_symbol() { + return None; + } + let unpublished = self.doc.edited; + Some( + div() + .h(px(30.0)) + .flex_none() + .mb(px(render::GAP)) + .px(px(12.0)) + .rounded(px(8.0)) + .flex() + .items_center() + .gap(px(8.0)) + .bg(Theme::accent_soft()) + .child(icon("symbol", Theme::accent()).size(px(13.0))) + .child( + div() + .text_size(px(11.5)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(Theme::text()) + .child("Symbol"), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child(if unpublished { + "Changes here reach other movies when you publish them." + } else { + "Published. Movies that use it can update to this version." + }), + ) + .when(unpublished, |d| { + d.child( + div() + .id("publish") + .h(px(22.0)) + .px(px(10.0)) + .rounded(px(6.0)) + .flex() + .items_center() + .cursor_pointer() + .bg(Theme::text()) + .text_color(Theme::ground()) + .text_size(px(11.0)) + .font_weight(FontWeight::SEMIBOLD) + .hover(|s| s.bg(Theme::subtext())) + .child("Publish ⌘S") + .on_click(cx.listener(|this, _, window, cx| this.save(&Save, window, cx))), + ) + }) + .into_any_element(), + ) + } + + /// A symbol in your symbols: its poster on a see-through ground, its + /// length, and a menu on hover. + pub(super) fn symbol_card(&self, entry: symbols::Entry, cx: &mut Context) -> AnyElement { + let card = self.card_width; + let name = entry.name.clone(); + let group: SharedString = format!("symbol-{name}").into(); + let duration = entry.info.as_ref().map_or(3 * SECOND, |i| i.duration.max(SECOND / 10)); + let face = match entry.poster() { + Some(poster) => img(poster) + .size_full() + .object_fit(ObjectFit::Contain) + .into_any_element(), + None => icon("symbol", Theme::overlay()).size(px(20.0)).into_any_element(), + }; + let drag = MediaDrag { + media: MediaId(0), + name: name.clone().into(), + duration, + generator: None, + symbol: Some(name.clone().into()), + }; + let (add, menu) = (name.clone(), name.clone()); + let badge = |child: AnyElement| { + div() + .absolute() + .h(px(16.0)) + .px(px(4.0)) + .rounded(px(4.0)) + .bg(gpui::rgba(0x000000a6)) + .flex() + .items_center() + .gap(px(3.0)) + .child(child) + }; + div() + .id(SharedString::from(format!("symbol-card-{name}"))) + .group(group.clone()) + .w(px(card)) + .flex() + .flex_col() + .gap(px(5.0)) + .cursor_grab() + .child( + div() + .w(px(card)) + .h(px(card * render::CARD_ASPECT)) + .rounded(px(7.0)) + .overflow_hidden() + .relative() + .bg(Theme::crust()) + .border_1() + .border_color(Theme::edge()) + .group_hover(group.clone(), |s| s.border_color(Theme::surface2())) + .child(super::shelf::dark_checkerboard()) + .child( + div() + .absolute() + .inset(px(5.0)) + .flex() + .items_center() + .justify_center() + .child(face), + ) + .child( + badge(icon("symbol", Theme::text()).size(px(10.0)).into_any_element()) + .top(px(4.0)) + .left(px(4.0)), + ) + .child( + badge( + div() + .font_family(MONO_FONT) + .text_size(px(9.5)) + .text_color(Theme::text()) + .child(format!("0:{:02}", secs(duration).round() as u64)) + .into_any_element(), + ) + .bottom(px(4.0)) + .right(px(4.0)), + ) + .child( + div() + .id(SharedString::from(format!("symbol-menu-{name}"))) + .absolute() + .top(px(4.0)) + .right(px(4.0)) + .size(px(20.0)) + .rounded(px(5.0)) + .bg(gpui::rgba(0x000000b3)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .invisible() + .group_hover(group.clone(), |s| s.visible()) + .child(icon("more", Theme::text()).size(px(12.0))) + .tooltip(tip("Edit, rename, duplicate, show in Finder, move to Trash", None)) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.overlay = Overlay::SymbolMenu { + name: menu.clone(), + index: 0, + }; + window.focus(&this.palette_focus); + cx.notify(); + })), + ), + ) + .child( + div() + .w(px(card)) + .px(px(1.0)) + .text_size(px(10.5)) + .text_color(Theme::muted()) + .truncate() + .child(name), + ) + .tooltip(tip("Click to add at the playhead, or drag onto the timeline", None)) + .on_drag(drag, |drag: &MediaDrag, _, _, cx| { + cx.new(|_| DragChip(drag.name.clone())) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.add_symbol(add.clone(), None, None, cx); + cx.notify(); + })) + .into_any_element() + } + + /// Does what the symbol menu's row `index` says. + pub(super) fn symbol_menu_pick(&mut self, name: &str, index: usize, window: &mut Window, cx: &mut Context) { + self.close_overlay(window, cx); + let name = name.to_string(); + match index { + 0 => self.add_symbol(name, None, None, cx), + 1 => self.edit_symbol(&name), + 2 => self.open_naming(Naming::Rename(name.clone()), name, window, cx), + 3 => { + let mut copy = format!("{name} copy"); + let mut n = 2; + while symbols::taken(©) { + copy = format!("{name} copy {n}"); + n += 1; + } + self.open_naming(Naming::Duplicate(name), copy, window, cx); + } + 4 => cx.reveal_path(&symbols::file(&name)), + 5 => self.library_op(LibraryOp::Delete(name), None, cx), + _ => {} + } + cx.notify(); + } + + /// The symbol menu and the naming sheet, as overlay content. + pub(super) fn symbol_overlay(&self, cx: &mut Context) -> Option { + match &self.overlay { + Overlay::SymbolMenu { name, index } => { + let index = *index; + let rows: Vec = MENU + .iter() + .enumerate() + .map(|(i, (label, glyph))| { + let active = i == index; + let danger = i == MENU.len() - 1; + let pick = name.clone(); + div() + .id(("symbol-menu-row", i)) + .h(px(32.0)) + .px(px(12.0)) + .rounded(px(7.0)) + .flex() + .items_center() + .gap(px(10.0)) + .cursor_pointer() + .when(active, |d| d.bg(Theme::lane())) + .hover(|s| s.bg(Theme::lane())) + .child(icon(glyph, if danger { Theme::accent() } else { Theme::overlay() }).size(px(13.0))) + .child( + div() + .text_size(px(12.5)) + .text_color(if danger { Theme::accent() } else { Theme::text() }) + .child(*label), + ) + .on_click( + cx.listener(move |this, _, window, cx| this.symbol_menu_pick(&pick, i, window, cx)), + ) + .into_any_element() + }) + .collect(); + Some( + div() + .flex() + .flex_col() + .child( + div() + .h(px(44.0)) + .px(px(16.0)) + .flex() + .items_center() + .gap(px(10.0)) + .border_b_1() + .border_color(Theme::rule()) + .child(icon("symbol", Theme::accent()).size(px(14.0))) + .child(div().text_size(px(14.0)).text_color(Theme::text()).child(name.clone())), + ) + .child(div().p(px(6.0)).flex().flex_col().children(rows)) + .child( + div() + .h(px(30.0)) + .px(px(14.0)) + .flex() + .items_center() + .border_t_1() + .border_color(Theme::rule()) + .text_size(px(10.5)) + .text_color(Theme::overlay()) + .child("↑↓ move · ⏎ choose · esc close · Trash keeps it until emptied"), + ) + .into_any_element(), + ) + } + Overlay::SymbolName { naming, text, all } => { + let (title, action, detail): (String, &str, String) = match naming { + Naming::Make { clips, .. } => { + let n = clips.len(); + ( + "Make a symbol".into(), + "Make", + format!( + "{n} clip{} become one use of it, here; it goes in your symbols for any movie.", + if n == 1 { "" } else { "s" }, + ), + ) + } + Naming::Rename(from) => ( + format!("Rename {from}"), + "Rename", + "Movies that use it follow the new name.".into(), + ), + Naming::Duplicate(from) => ( + format!("Duplicate {from}"), + "Duplicate", + "A symbol of its own: changing one leaves the other.".into(), + ), + Naming::Keep(_) => ( + "Keep in my symbols".into(), + "Keep", + "Puts this movie’s copy in your symbols, to reuse and edit.".into(), + ), + }; + // Which titles each use can change: ticked ones become placeholders. + let choices: Vec = match naming { + Naming::Make { clips, plain, .. } => { + let mut titles: Vec<(usize, Time, ClipId, String)> = self + .doc + .project + .clips() + .filter(|(_, c)| clips.contains(&c.id)) + .filter_map(|(t, c)| match &c.generator { + Some(Generator::Text(title)) => Some(( + t, + c.start, + c.id, + title.text.lines().next().unwrap_or("").trim().to_string(), + )), + _ => None, + }) + .collect(); + titles.sort_by_key(|(t, start, _, _)| (*t, *start)); + titles + .into_iter() + .map(|(_, _, clip, words)| { + let on = !plain.contains(&clip); + div() + .id(("symbol-title-choice", clip.0 as usize)) + .h(px(28.0)) + .px(px(8.0)) + .rounded(px(6.0)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .hover(|s| s.bg(Theme::lane())) + .child( + div() + .size(px(14.0)) + .rounded(px(4.0)) + .border_1() + .border_color(if on { Theme::accent() } else { Theme::surface2() }) + .when(on, |d| d.bg(Theme::accent())) + .flex() + .items_center() + .justify_center() + .when(on, |d| { + d.child(div().size(px(6.0)).rounded_full().bg(Theme::ground())) + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.0)) + .text_color(if on { Theme::text() } else { Theme::subtext() }) + .child(format!("“{words}”")), + ) + .child(div().text_size(px(10.5)).text_color(Theme::overlay()).child(if on { + "each use can change it" + } else { + "stays as it is" + })) + .on_click(cx.listener(move |this, _, _, cx| { + if let Overlay::SymbolName { + naming: Naming::Make { plain, .. }, + .. + } = &mut this.overlay + { + if let Some(i) = plain.iter().position(|c| *c == clip) { + plain.remove(i); + } else { + plain.push(clip); + } + } + cx.notify(); + })) + .into_any_element() + }) + .collect() + } + _ => Vec::new(), + }; + let taken = symbols::taken(text.trim()) + && !matches!(naming, Naming::Rename(from) if from.eq_ignore_ascii_case(text.trim())); + let (all, text) = (*all, text.clone()); + Some( + div() + .flex() + .flex_col() + .child( + div() + .h(px(44.0)) + .px(px(16.0)) + .flex() + .items_center() + .gap(px(10.0)) + .border_b_1() + .border_color(Theme::rule()) + .child(icon("symbol", Theme::accent()).size(px(14.0))) + .child(div().text_size(px(14.0)).text_color(Theme::text()).child(title)), + ) + .child( + div() + .p(px(16.0)) + .flex() + .flex_col() + .gap(px(10.0)) + .child( + div() + .h(px(34.0)) + .px(px(10.0)) + .rounded(px(8.0)) + .bg(Theme::crust()) + .border_1() + .border_color(if taken { Theme::accent() } else { Theme::surface2() }) + .flex() + .items_center() + .text_size(px(14.0)) + .text_color(Theme::text()) + .child( + div() + .when(all && !text.is_empty(), |d| { + d.bg(Theme::accent_soft()).rounded(px(3.0)) + }) + .child(text), + ) + .when(!all, |d| d.child(div().w(px(1.5)).h(px(17.0)).bg(Theme::accent()))), + ) + .child(if taken { + note("You already have a symbol with this name.").text_color(Theme::accent()) + } else { + note(detail) + }) + .when(!choices.is_empty(), |d| { + d.child( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .pt(px(4.0)) + .text_size(px(10.5)) + .text_color(Theme::overlay()) + .child("PLACEHOLDERS: what each use can change"), + ) + .children(choices), + ) + }), + ) + .child( + div() + .h(px(30.0)) + .px(px(14.0)) + .flex() + .items_center() + .border_t_1() + .border_color(Theme::rule()) + .text_size(px(10.5)) + .text_color(Theme::overlay()) + .child(format!("⏎ {action} · esc cancel")), + ) + .into_any_element(), + ) + } + _ => None, + } + } +} diff --git a/crates/splice-app/src/editor/shelf.rs b/crates/splice-app/src/editor/shelf.rs index 331c3c9..2b092ca 100644 --- a/crates/splice-app/src/editor/shelf.rs +++ b/crates/splice-app/src/editor/shelf.rs @@ -152,16 +152,22 @@ pub(crate) fn generated_name(generator: &Generator) -> String { } /// Light and dark squares, for showing what is see-through. -fn checkerboard() -> impl IntoElement { +pub(super) fn checkerboard() -> impl IntoElement { + checker(gpui::rgb(0x9aa0ad), gpui::rgb(0x6b7180)) +} + +/// A quiet checkerboard, for pictures that are mostly see-through (symbols), +/// so their light words still read. +pub(super) fn dark_checkerboard() -> impl IntoElement { + checker(gpui::rgb(0x2a2e38), gpui::rgb(0x20232b)) +} + +fn checker(light: Rgba, dark: Rgba) -> impl IntoElement { const SQUARE: f32 = 8.0; - let rows = (0..12).map(|row| { + let rows = (0..12).map(move |row| { div().flex().children((0..24).map(move |col| { - let light = (row + col) % 2 == 0; - div().flex_none().size(px(SQUARE)).bg(if light { - gpui::rgb(0x9aa0ad) - } else { - gpui::rgb(0x6b7180) - }) + let on = (row + col) % 2 == 0; + div().flex_none().size(px(SQUARE)).bg(if on { light } else { dark }) })) }); div() @@ -178,13 +184,20 @@ fn checkerboard() -> impl IntoElement { const GENERATED_DURATION: Time = 3 * SECOND; impl Editor { - pub(super) fn shelf_tabs(&mut self, count: usize, cx: &mut Context) -> impl IntoElement + use<> { - let tab = |this: &Self, shelf: Shelf, label: &'static str, cx: &mut Context| { + /// The shelf tabs: words when there is room, icons when the panel is narrow. + pub(super) fn shelf_tabs( + &mut self, + count: usize, + compact: bool, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let tab = |this: &Self, shelf: Shelf, label: &'static str, glyph: &'static str, cx: &mut Context| { let on = this.shelf == shelf; + let color = if on { Theme::text() } else { Theme::overlay() }; div() .id(label) .h(px(24.0)) - .px(px(8.0)) + .px(px(if compact { 7.0 } else { 8.0 })) .rounded(px(6.0)) .flex() .items_center() @@ -192,13 +205,19 @@ impl Editor { .cursor_pointer() .when(on, |d| d.bg(Theme::lane())) .hover(|s| s.bg(Theme::lane())) - .child( - div() - .text_size(px(10.5)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(if on { Theme::text() } else { Theme::overlay() }) - .child(label.to_uppercase()), - ) + .map(|d| { + if compact { + d.child(icon(glyph, color).size(px(13.0))).tooltip(tip(label, None)) + } else { + d.child( + div() + .text_size(px(10.5)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(color) + .child(label.to_uppercase()), + ) + } + }) .when(shelf == Shelf::Media && count > 0, |d| { d.child( div() @@ -209,6 +228,9 @@ impl Editor { }) .on_click(cx.listener(move |this, _, _, cx| { this.shelf = shelf; + if shelf == Shelf::Symbols { + this.poll_symbols(cx); + } cx.notify(); })) }; @@ -216,9 +238,10 @@ impl Editor { .flex() .items_center() .gap(px(2.0)) - .child(tab(self, Shelf::Media, "Media", cx)) - .child(tab(self, Shelf::Text, "Text", cx)) - .child(tab(self, Shelf::Color, "Colour", cx)) + .child(tab(self, Shelf::Media, "Media", "film", cx)) + .child(tab(self, Shelf::Text, "Text", "text", cx)) + .child(tab(self, Shelf::Color, "Colour", "swatch", cx)) + .child(tab(self, Shelf::Symbols, "Symbols", "symbol", cx)) } pub(super) fn render_shelf(&mut self, cx: &mut Context) -> AnyElement { @@ -271,6 +294,7 @@ impl Editor { name: preset.name.into(), duration: GENERATED_DURATION, generator: Some(generator.clone()), + symbol: None, }; self.shelf_card( preset.name, @@ -298,6 +322,7 @@ impl Editor { name: name.into(), duration: GENERATED_DURATION, generator: Some(generator.clone()), + symbol: None, }; // See-through colours sit on a checkerboard, so they read as see-through. let see_through = color & 0xff < 0xff; @@ -319,6 +344,25 @@ impl Editor { .collect(); self.shelf_grid("Backgrounds and title cards. Tint darkens what is under it.", cards) } + Shelf::Symbols => { + let cards: Vec = self + .symbols + .list + .clone() + .into_iter() + .map(|entry| self.symbol_card(entry, cx)) + .collect(); + self.shelf_grid( + if cards.is_empty() { + "Pieces you reuse in any movie: intros, lower thirds, logos. Select clips and \ + choose Make Symbol (⌥⌘K or +); titles become placeholders you fill in each time." + } else { + "Click to add at the playhead, or drag onto the timeline; fill in each use in the \ + inspector. To add one, select clips and choose + (⌥⌘K)." + }, + cards, + ) + } } } @@ -399,7 +443,7 @@ impl Editor { } /// The layer just above everything under the playhead for `duration`. - fn layer_above(&self, start: Time, duration: Time) -> usize { + pub(super) fn layer_above(&self, start: Time, duration: Time) -> usize { self.doc .project .tracks @@ -441,6 +485,22 @@ impl Editor { } pub(super) fn shelf_header_action(&mut self, cx: &mut Context) -> Option { + if self.shelf == Shelf::Symbols { + let ready = !self.selection.is_empty(); + return Some( + render::icon_button("make-symbol", "plus", ready) + .tooltip(tip( + if ready { + "Make a symbol from the selection" + } else { + "Select clips to make a symbol" + }, + Some("⌥⌘K"), + )) + .on_click(cx.listener(|this, _, window, cx| this.make_symbol_action(&MakeSymbol, window, cx))) + .into_any_element(), + ); + } (self.shelf == Shelf::Media).then(|| { render::icon_button("import", "plus", true) .tooltip(tip("Import media", Some("⌘I"))) diff --git a/crates/splice-app/src/editor/stage.rs b/crates/splice-app/src/editor/stage.rs index 5b83131..6f2ff13 100644 --- a/crates/splice-app/src/editor/stage.rs +++ b/crates/splice-app/src/editor/stage.rs @@ -137,7 +137,19 @@ impl Editor { image, natural: export::natural_size(source, rendered, canvas.0, canvas.1), pose, - visible: visible_part(clip.look.mask.as_ref()), + visible: { + // A see-through render is picked and outlined by what it draws. + let mut v = visible_part(clip.look.mask.as_ref()); + if let Source::Media(m) = source + && let Some([x, y, w, h]) = m.content + { + v = [v[0].max(x), v[1].max(y), v[2].min(x + w), v[3].min(y + h)]; + if v[2] <= v[0] || v[3] <= v[1] { + v = [x, y, x + w, y + h]; + } + } + v + }, }); } layers @@ -155,6 +167,7 @@ impl Editor { let placed = self.stage_layers.clone(); placed.borrow_mut().clear(); + let see_through = self.editing_a_symbol(); let ground = canvas( move |bounds, _, _| { // The largest rectangle with the canvas's shape, with room to breathe. @@ -182,6 +195,35 @@ impl Editor { transparent_black(), Default::default(), )); + // A symbol is drawn over whatever uses it: show where it is clear. + if see_through { + let square = 10.0; + let (x0, y0) = (f32::from(stage.left()), f32::from(stage.top())); + let (w, h) = (f32::from(stage.size.width), f32::from(stage.size.height)); + let (cols, rows) = ((w / square).ceil() as i32, (h / square).ceil() as i32); + for row in 0..rows { + for col in 0..cols { + let light = (row + col) % 2 == 0; + let (x, y) = (x0 + col as f32 * square, y0 + row as f32 * square); + let cell = Bounds::new( + point(px(x), px(y)), + size(px(square.min(x0 + w - x)), px(square.min(y0 + h - y))), + ); + window.paint_quad(quad( + cell, + Corners::default(), + if light { + gpui::rgb(0x2a2e38) + } else { + gpui::rgb(0x20232b) + }, + px(0.0), + transparent_black(), + Default::default(), + )); + } + } + } }, ) .absolute() diff --git a/crates/splice-app/src/editor/story.rs b/crates/splice-app/src/editor/story.rs index b248008..ae925d8 100644 --- a/crates/splice-app/src/editor/story.rs +++ b/crates/splice-app/src/editor/story.rs @@ -727,7 +727,7 @@ impl Editor { this.add_references(id, paths.paths().to_vec(), None, None, cx); })) .on_drop(cx.listener(move |this, drag: &MediaDrag, _, cx| { - if drag.generator.is_none() { + if drag.generator.is_none() && drag.symbol.is_none() { let media = drag.media; if let Some(clip) = this.edit(cx, |p| p.fill_beat(id, media)) { this.selection = vec![clip]; diff --git a/crates/splice-app/src/editor/workbench.rs b/crates/splice-app/src/editor/workbench.rs index bc5fac3..1f2909d 100644 --- a/crates/splice-app/src/editor/workbench.rs +++ b/crates/splice-app/src/editor/workbench.rs @@ -214,6 +214,17 @@ fn rgb(color: u32) -> Hsla { impl Editor { pub(super) fn open_workbench(&mut self, media: MediaId, window: &mut Window, cx: &mut Context) { window.focus(&self.focus); + // A symbol is a timeline of its own: it opens in its own window. + if let Some(Recipe::Symbol(symbol)) = self.doc.project.media(media).and_then(|m| m.recipe.as_ref()) { + match self.library_name(symbol) { + Some(name) => self.edit_symbol(&name), + None => self.say(format!( + "{} is not in your symbols on this Mac; keep it from the inspector to edit it", + symbol.name + )), + } + return; + } if self.workbench.as_ref().is_some_and(|b| b.media == media) { return; } @@ -295,7 +306,7 @@ impl Editor { let body = match media.recipe.clone() { Some(Recipe::Score(score)) => self.score_body(id, &score, cx).into_any_element(), Some(Recipe::Scene(scene)) => self.scene_body(id, &scene, window, cx).into_any_element(), - None => div().into_any_element(), + Some(Recipe::Symbol(_)) | None => div().into_any_element(), }; panel() .id("workbench") @@ -512,6 +523,8 @@ impl Editor { }) .collect(); + // Mute and solo can leave nothing audible; say so where it is seen. + let silence = score.silence(); // What the selected note is, in words. let readout = selected.first().and_then(|&(p, ph, n)| { let part = score.parts.get(p)?; @@ -656,10 +669,14 @@ impl Editor { .text_size(px(11.5)) .text_color(if readout.is_some() { Theme::text() + } else if silence.is_some() { + rgb(0xf2c14e).into() } else { Theme::overlay() }) - .child(readout.unwrap_or_else(|| "Click a note to see what it is".into())), + .child( + readout.unwrap_or_else(|| silence.unwrap_or("Click a note to see what it is").into()), + ), ) .child( div() @@ -974,14 +991,14 @@ impl Editor { toggle(format!("mute-{index}"), "M", muted, Theme::overlay().into()) .tooltip(tip("Mute", None)) .on_click(cx.listener(move |this, _, _, cx| { - this.change_score(cx, |s| s.parts[index].mute = !s.parts[index].mute); + this.change_score(cx, |s| s.toggle_mute(index)); })), ) .child( toggle(format!("solo-{index}"), "S", solo, rgb(0xf2c14e)) .tooltip(tip("Solo", None)) .on_click(cx.listener(move |this, _, _, cx| { - this.change_score(cx, |s| s.parts[index].solo = !s.parts[index].solo); + this.change_score(cx, |s| s.toggle_solo(index)); })), ), ) diff --git a/crates/splice-app/src/icons.rs b/crates/splice-app/src/icons.rs index 13e9ae5..f1a90f2 100644 --- a/crates/splice-app/src/icons.rs +++ b/crates/splice-app/src/icons.rs @@ -85,6 +85,16 @@ fn icon(name: &str) -> Option<&'static str> { "scene" => { r#""# } + // Four diamonds: a piece kept for reuse, as in design tools. + "symbol" => { + r#""# + } + "more" => { + r#""# + } + "folder" => { + r#""# + } "flag" => r#""#, "record" => r#""#, "close" => r#""#, diff --git a/crates/splice-app/src/main.rs b/crates/splice-app/src/main.rs index ba1fe93..19f7b64 100644 --- a/crates/splice-app/src/main.rs +++ b/crates/splice-app/src/main.rs @@ -14,6 +14,7 @@ mod pointer; mod recorder; mod scenes; mod session; +mod symbols; mod theme; mod timeline; mod titles; diff --git a/crates/splice-app/src/session.rs b/crates/splice-app/src/session.rs index 3b6302e..68b97ec 100644 --- a/crates/splice-app/src/session.rs +++ b/crates/splice-app/src/session.rs @@ -169,6 +169,10 @@ impl Document { self.dirty = false; self.edited = false; self.last_write = Some(Instant::now()); + // Bytes no kept version uses (old renders, replaced media) go. + if let Err(e) = self.store.collect_garbage() { + eprintln!("splice: could not tidy {}: {e}", self.path().display()); + } Ok(()) } diff --git a/crates/splice-app/src/symbols.rs b/crates/splice-app/src/symbols.rs new file mode 100644 index 0000000..39d262e --- /dev/null +++ b/crates/splice-app/src/symbols.rs @@ -0,0 +1,534 @@ +//! The symbol library: pieces of movies kept for reuse in any project. Each +//! symbol is a small Splice project file in one folder, so editing one is +//! opening it in Splice. What it publishes is its latest *saved* version, so +//! work in progress never reaches the projects that use it. Each file keeps +//! the symbol's identity, so renaming it in Finder or here keeps its uses. +//! Everything here blocks; run it off the main thread. + +use crate::ffmpeg; +use crate::library::cache_path; +use crate::session::dirs; +use crate::titles; +use anyhow::{Context, Result, bail}; +use splice_core::export; +use splice_core::model::{Media, Project, Recipe, Settings}; +use splice_core::store::{EXTENSION, Store, VersionKind}; +use splice_core::symbol::{Symbol, version_of}; +use splice_core::time::Time; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::SystemTime; + +/// Renders of a symbol with this renderer name the same bytes. +pub const ENGINE: &str = "splice-symbol-2|prores4444"; + +/// The meta key a symbol's identity is kept under in its file. +const ID: &str = "symbol_id"; + +/// Where the library lives. +pub fn folder() -> PathBuf { + let dir = dirs().data.join("Symbols"); + let _ = std::fs::create_dir_all(&dir); + dir +} + +pub fn file(name: &str) -> PathBuf { + folder().join(format!("{name}.{EXTENSION}")) +} + +/// The poster for one version of a symbol: its content, cropped, on clear. +/// Posters live in the cache, named by version, so the library folder holds +/// only symbols and a changed symbol is never shown with an old picture. +pub fn poster(name: &str, version: &str) -> PathBuf { + let dir = dirs().cache.join("symbols"); + let _ = std::fs::create_dir_all(&dir); + dir.join(format!("{name}-{version}.png")) +} + +/// A fresh identity for a new symbol. +pub fn new_id() -> String { + let seed = format!( + "{:?}|{}|{:?}", + SystemTime::now(), + std::process::id(), + std::thread::current().id() + ); + blake3::hash(seed.as_bytes()).to_hex()[..20].to_string() +} + +/// A symbol in the library, as listed (cheap: no reading inside the file). +#[derive(Clone, Debug)] +pub struct Entry { + pub name: String, + pub modified: SystemTime, + /// What reading inside it found, once read. + pub info: Option, +} + +/// What a symbol's file says about it. +#[derive(Clone, Debug)] +pub struct Info { + pub id: String, + /// The published (last saved) version. + pub version: String, + /// Every published version, oldest first, so "newer" can be told from + /// "different". + pub lineage: Vec, + pub duration: Time, + pub settings: Settings, +} + +impl Entry { + /// Its poster, if drawn. + pub fn poster(&self) -> Option { + let info = self.info.as_ref()?; + Some(poster(&self.name, &info.version)).filter(|p| p.exists()) + } +} + +/// The library, newest first. +pub fn list() -> Vec { + let Ok(read) = std::fs::read_dir(folder()) else { + return Vec::new(); + }; + let mut out: Vec = read + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == EXTENSION)) + .filter_map(|path| { + let name = path.file_stem()?.to_string_lossy().to_string(); + let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok()?; + Some(Entry { + name, + modified, + info: None, + }) + }) + .collect(); + out.sort_by_key(|e| std::cmp::Reverse(e.modified)); + out +} + +/// Reads what is inside a library symbol: its identity (given one now if it +/// has none), published version and its history. +pub fn info(name: &str) -> Result { + let store = Store::open(&file(name))?; + let id = match store.meta(ID)? { + Some(id) => id, + None => { + let id = new_id(); + store.set_meta(ID, &id)?; + id + } + }; + let saved = store.saved()?; + let latest = match saved.last() { + Some(p) => p.clone(), + None => store + .latest()? + .with_context(|| format!("the symbol {name:?} is empty"))?, + }; + Ok(Info { + id, + version: version_of(&latest), + lineage: saved.iter().map(version_of).collect(), + duration: latest.duration(), + settings: latest.settings, + }) +} + +/// A name that is safe as a file name. +pub fn clean_name(name: &str) -> Result { + let name: String = name + .trim() + .chars() + .map(|c| if matches!(c, '/' | ':' | '\\') { '-' } else { c }) + .collect(); + if name.is_empty() || name.starts_with('.') { + bail!("give the symbol a name"); + } + Ok(name) +} + +/// Whether a symbol of that name is in the library, in any case. +pub fn taken(name: &str) -> bool { + list().iter().any(|e| e.name.eq_ignore_ascii_case(name)) +} + +/// The symbol's published timeline and identity. +pub fn read(name: &str) -> Result<(String, Project)> { + let path = file(name); + if !path.exists() { + bail!("there is no symbol called {name:?} in your symbols"); + } + let info = info(name)?; + let store = Store::open(&path)?; + let project = match store.latest_saved()? { + Some(p) => p, + None => store + .latest()? + .with_context(|| format!("the symbol {name:?} has nothing saved"))?, + }; + Ok((info.id, project)) +} + +/// Every piece of media `project` needs bytes for: its own, and that of +/// symbols inside it, at any depth, and what fills them. +fn all_media(project: &Project, out: &mut Vec) { + for media in &project.media { + out.push(media.clone()); + if let Some(Recipe::Symbol(symbol)) = &media.recipe { + all_media(&symbol.filled(), out); + } + } +} + +/// Copies the bytes `project` needs from `from` (a project file) into `to`, +/// skipping what `to` has. Media the cache holds is copied from there. +pub fn copy_media(project: &Project, from: &Path, to: &Store) -> Result<()> { + let source = Store::open(from)?; + let mut all = Vec::new(); + all_media(project, &mut all); + for media in all { + if to.has_media(&media.hash)? { + continue; + } + let cached = cache_path(&dirs().cache, &media); + if !cached.exists() { + if !source.has_media(&media.hash)? { + continue; + } + source.extract_media(&media.hash, &cached)?; + } + to.put_media(&media.hash, &media.ext, &cached)?; + } + Ok(()) +} + +/// Writes `project` to the library as a new symbol `name`, its media copied +/// from the project file `from`. `id` keeps an identity (a copy of a symbol +/// gets a new one). Never replaces a symbol. Returns its identity. +pub fn save(name: &str, project: &Project, from: &Path, id: Option<&str>) -> Result { + let path = file(name); + if path.exists() { + bail!("there is already a symbol called {name:?}"); + } + let partial = path.with_extension("partial"); + let _ = std::fs::remove_file(&partial); + let id = id.map_or_else(new_id, str::to_string); + { + let store = Store::open(&partial)?; + copy_media(project, from, &store)?; + store.set_meta(ID, &id)?; + store.save_version(project, VersionKind::Save, Some("Made a symbol"))?; + } + std::fs::rename(&partial, &path)?; + let _ = draw_poster(project, from, &poster(name, &version_of(project))); + Ok(id) +} + +/// Renames a library symbol. Its uses follow it, by identity. +pub fn rename(from: &str, to: &str) -> Result<()> { + let to = clean_name(to)?; + if !file(from).exists() { + bail!("there is no symbol called {from:?}"); + } + if to.eq_ignore_ascii_case(from) && to != from { + // A change of case only: through a temporary name, for case-blind disks. + let step = folder().join(format!(".rename-{}.{EXTENSION}", new_id())); + std::fs::rename(file(from), &step)?; + std::fs::rename(&step, file(&to))?; + return Ok(()); + } + if taken(&to) { + bail!("there is already a symbol called {to:?}"); + } + std::fs::rename(file(from), file(&to))?; + Ok(()) +} + +/// Copies a library symbol under a new name, as a symbol of its own. +pub fn duplicate(from: &str, to: &str) -> Result<()> { + let to = clean_name(to)?; + if taken(&to) { + bail!("there is already a symbol called {to:?}"); + } + let (_, project) = read(from)?; + save(&to, &project, &file(from), None)?; + Ok(()) +} + +/// Moves a library symbol to the Trash. Projects that use it keep their copy. +pub fn delete(name: &str) -> Result { + let path = file(name); + if !path.exists() { + bail!("there is no symbol called {name:?}"); + } + let home = directories::BaseDirs::new() + .map(|b| b.home_dir().to_path_buf()) + .context("no home folder")?; + let trash = home.join(".Trash"); + std::fs::create_dir_all(&trash)?; + let mut dest = trash.join(format!("{name}.{EXTENSION}")); + let mut n = 2; + while dest.exists() { + dest = trash.join(format!("{name} {n}.{EXTENSION}")); + n += 1; + } + std::fs::rename(&path, &dest)?; + Ok(dest) +} + +/// Makes sure every file `project` needs is in the media cache: taken from +/// the project file `store`, or, for a symbol's use whose render was never +/// kept, rendered now (at `project`'s canvas). +pub fn unpack(project: &Project, store: &Path) -> Result<()> { + let mut opened: Option = None; + for media in &project.media { + let cached = cache_path(&dirs().cache, media); + if cached.exists() { + continue; + } + if opened.is_none() { + opened = Some(Store::open(store)?); + } + let file = opened.as_ref().expect("opened"); + if file.has_media(&media.hash)? { + file.extract_media(&media.hash, &cached)?; + } else if let Some(Recipe::Symbol(inner)) = &media.recipe { + render(inner, project.settings, store, &cached, |_| {})?; + } + } + Ok(()) +} + +fn resolver(settings: Settings) -> titles::Resolver Option> { + titles::Resolver { + media: |m: &Media| Some(cache_path(&dirs().cache, m)).filter(|p| p.exists()), + dir: titles::drawn_dir(), + canvas: (settings.width, settings.height), + } +} + +/// Where a picture draws anything: (x, y, w, h) in pixels, if anywhere. +fn opaque_box(pixmap: &tiny_skia::Pixmap) -> Option<(u32, u32, u32, u32)> { + let (w, h) = (pixmap.width(), pixmap.height()); + let data = pixmap.data(); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for y in 0..h { + for x in 0..w { + if data[((y * w + x) * 4 + 3) as usize] > 8 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + } + (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0)) +} + +/// A picture of the symbol for the shelf: the moment halfway through, cropped +/// to what it shows (with a margin), see-through around it. +pub fn draw_poster(project: &Project, store: &Path, out: &Path) -> Result<()> { + unpack(project, store)?; + let t = (project.duration() / 2).max(0); + let full = out.with_extension("full.png"); + let plan = export::plan_still_layer(project, &resolver(project.settings), t, &full).map_err(anyhow::Error::msg)?; + let output = ffmpeg::export_command()?.args(&plan.args).output()?; + if !output.status.success() { + bail!( + "{}", + String::from_utf8_lossy(&output.stderr) + .lines() + .last() + .unwrap_or("ffmpeg failed") + ); + } + let pixmap = tiny_skia::Pixmap::load_png(&full)?; + let _ = std::fs::remove_file(&full); + let (w, h) = (pixmap.width(), pixmap.height()); + let (x, y, bw, bh) = opaque_box(&pixmap).unwrap_or((0, 0, w, h)); + // A margin, and no flatter than the shelf's cards so it fills them. + let margin = (bw.max(bh) as f32 * 0.12) as u32; + let (mut cw, mut ch) = (bw + 2 * margin, bh + 2 * margin); + if (cw as f32) < ch as f32 * 1.5 { + cw = (ch as f32 * 1.5) as u32; + } else if (ch as f32) < cw as f32 / 1.9 { + ch = (cw as f32 / 1.9) as u32; + } + let (cw, ch) = (cw.min(w).max(2), ch.min(h).max(2)); + let cx = (x + bw / 2).saturating_sub(cw / 2).min(w - cw); + let cy = (y + bh / 2).saturating_sub(ch / 2).min(h - ch); + let mut crop = tiny_skia::Pixmap::new(cw, ch).context("poster size")?; + crop.draw_pixmap( + -(cx as i32), + -(cy as i32), + pixmap.as_ref(), + &tiny_skia::PixmapPaint::default(), + tiny_skia::Transform::identity(), + None, + ); + crop.save_png(out)?; + Ok(()) +} + +/// Where a render draws anything over its whole length, as fractions of its +/// frame (x, y, w, h), sampled a few times a second. +fn content_of(movie: &Path) -> Option<[f32; 4]> { + let output = ffmpeg::export_command() + .ok()? + .args(["-hide_banner", "-nostdin", "-i"]) + .arg(movie) + .args(["-vf", "fps=6,alphaextract,bbox=min_val=12", "-f", "null", "-"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stderr); + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + let number = |line: &str, key: &str| -> Option { + let at = line.find(key)? + key.len(); + line[at..].split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() + }; + for line in text.lines().filter(|l| l.contains("x1:")) { + if let (Some(a), Some(b), Some(c), Some(d)) = ( + number(line, "x1:"), + number(line, "y1:"), + number(line, "x2:"), + number(line, "y2:"), + ) { + x0 = x0.min(a); + y0 = y0.min(b); + x1 = x1.max(c + 1); + y1 = y1.max(d + 1); + } + } + let size = text.lines().find_map(|l| { + let at = l.find("yuva444p")?; + l[at..].split([',', ' ']).find_map(|part| { + let (w, h) = part.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }) + })?; + (x1 > x0 && y1 > y0).then(|| { + [ + x0 as f32 / size.0, + y0 as f32 / size.1, + (x1 - x0) as f32 / size.0, + (y1 - y0) as f32 / size.1, + ] + }) +} + +/// Renders one use of a symbol in a movie of canvas `host` to `out` (a +/// see-through `.mov`), taking the media it needs from the project file +/// `store`. `progress` hears 0–1. Returns where it draws anything. +pub fn render( + symbol: &Symbol, + host: Settings, + store: &Path, + out: &Path, + mut progress: impl FnMut(f32), +) -> Result> { + // Inner pieces (other symbols in it) render at its own canvas. + unpack(&symbol.filled(), store)?; + 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 mut child = ffmpeg::export_command()? + .args(&plan.args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting ffmpeg")?; + let mut stderr = child.stderr.take().expect("piped"); + let errors = std::thread::spawn(move || { + let mut text = String::new(); + let _ = stderr.read_to_string(&mut text); + text + }); + let duration: Time = plan.duration.max(1); + for line in BufReader::new(child.stdout.take().expect("piped")) + .lines() + .map_while(Result::ok) + { + if let Some(t) = export::parse_progress(&line) { + progress((t as f32 / duration as f32).clamp(0.0, 1.0)); + } + } + let status = child.wait()?; + let errors = errors.join().unwrap_or_default(); + if !status.success() { + bail!("{}", errors.lines().last().unwrap_or("ffmpeg failed")); + } + let content = content_of(&partial); + std::fs::rename(&partial, out)?; + Ok(content) +} + +/// Opens a symbol in its own Splice window, to change it. +pub fn open_window(name: &str) -> Result<()> { + let path = file(name); + if !path.exists() { + bail!("there is no symbol called {name:?} in your symbols"); + } + let exe = std::env::current_exe()?; + std::process::Command::new(exe) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use splice_core::look::MotionKind; + use splice_core::model::{Generator, Title}; + use splice_core::time::SECOND; + + #[test] + fn names_are_safe_file_names() { + assert_eq!(clean_name(" Lower third ").unwrap(), "Lower third"); + assert_eq!(clean_name("a/b:c").unwrap(), "a-b-c"); + assert!(clean_name(" ").is_err()); + assert!(clean_name(".hidden").is_err()); + } + + /// A title that pops in, rendered as a use: it must show at full size, + /// not frozen at the size it starts from. + #[test] + fn a_popping_title_renders_whole() { + if !ffmpeg::available() { + return; + } + let mut project = Project::default(); + project.settings = Settings { + width: 640, + height: 360, + fps: 30, + }; + let id = project.insert_generated( + Generator::Text(Title { + text: "Hello".into(), + ..Title::default() + }), + 0, + 0, + 2 * SECOND, + ); + project.set_look(id, |l| l.enter.kind = MotionKind::Pop); + let symbol = Symbol::new("t", "Pop", project.clone()); + let dir = std::env::temp_dir().join(format!("splice-pop-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("pop.mov"); + let store = dir.join("empty.splice"); + Store::open(&store).unwrap(); + let content = render(&symbol, project.settings, &store, &out, |_| {}).unwrap(); + let [_, _, w, h] = content.expect("it draws something"); + assert!(w * 640.0 > 50.0 && h * 360.0 > 20.0, "{w} x {h}"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/splice-app/src/timeline.rs b/crates/splice-app/src/timeline.rs index 6907cb0..086ab8c 100644 --- a/crates/splice-app/src/timeline.rs +++ b/crates/splice-app/src/timeline.rs @@ -181,6 +181,8 @@ pub struct ClipArt { pub peaks: Option>>, pub size: (u32, u32), pub offline: bool, + /// A use of a symbol with a newer version published. + pub outdated: bool, } /// A media drag hovering over the timeline. @@ -350,9 +352,15 @@ pub fn paint(b: Bounds, frame: Frame, window: &mut Window, cx: &mut App) // Name on a quiet backdrop. let label: String = if art.offline { format!("{} (offline)", art.name) + } else if art.outdated { + format!("{} · update", art.name) } else { art.name.to_string() }; + // 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); + } let label_x = x0.max(GUTTER) + 6.0; // Under the ruler, the name stays on the part still in view. let y = y.max(top).min(y + ROW - 21.0); diff --git a/crates/splice-core/src/export.rs b/crates/splice-core/src/export.rs index 98f73e6..a85ee1b 100644 --- a/crates/splice-core/src/export.rs +++ b/crates/splice-core/src/export.rs @@ -12,6 +12,11 @@ use crate::model::{Clip, Generator, Media, MediaKind, Project, Source}; use crate::time::{Time, secs}; use std::path::{Path, PathBuf}; +/// Overlapping loud clips are held under full scale instead of clipping: a +/// look-ahead limiter at -0.25 dBFS that only acts on peaks. `level=0` keeps it +/// from raising quiet mixes; `latency=1` keeps sound in step with picture. +const LIMITER: &str = "alimiter=limit=0.97:attack=5:release=80:level=0:latency=1"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Quality { /// Small files, quick encode. @@ -65,12 +70,38 @@ pub fn plan(project: &Project, resolve: &impl Resolve, output: &Path, quality: Q if project.duration() <= 0 { return Err("the timeline is empty".into()); } - build(project, resolve, output, Some(quality), None) + build(project, resolve, output, Some(Out::Movie(quality)), None, false) +} + +/// Builds the ffmpeg arguments that render `project` to `output` (a `.mov`) +/// as a layer: ProRes 4444 that is see-through wherever nothing is drawn, +/// with its sound as PCM. This is how a symbol's timeline becomes one clip. +pub fn plan_layer(project: &Project, resolve: &impl Resolve, output: &Path) -> Result { + if project.duration() <= 0 { + return Err("the timeline is empty".into()); + } + build(project, resolve, output, Some(Out::Layer), None, true) +} + +/// What a movie export writes. +#[derive(Clone, Copy)] +enum Out { + Movie(Quality), + Layer, } /// The picture at timeline time `t`, composited exactly as export would, /// written as a still image (the format follows `output`'s extension). pub fn plan_still(project: &Project, resolve: &impl Resolve, t: Time, output: &Path) -> Result { + still(project, resolve, t, output, false) +} + +/// Like `plan_still`, see-through wherever nothing is drawn (write a PNG). +pub fn plan_still_layer(project: &Project, resolve: &impl Resolve, t: Time, output: &Path) -> Result { + still(project, resolve, t, output, true) +} + +fn still(project: &Project, resolve: &impl Resolve, t: Time, output: &Path, see_through: bool) -> Result { if t < 0 || t >= project.duration().max(1) { return Err(format!( "{:.3}s is outside the movie (0 to {:.3}s)", @@ -83,7 +114,7 @@ pub fn plan_still(project: &Project, resolve: &impl Resolve, t: Time, output: &P track.muted = true; track.clips.retain(|c| c.contains(t)); } - let mut plan = build(&moment, resolve, output, None, Some(t))?; + let mut plan = build(&moment, resolve, output, None, Some(t), see_through)?; plan.duration = project.frame(); Ok(plan) } @@ -106,9 +137,11 @@ fn build( project: &Project, resolve: &impl Resolve, output: &Path, - quality: Option, + out: Option, still_at: Option