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