diff --git a/README.md b/README.md index 638fa1a..7013fba 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,12 @@ yourself and your screen. Nothing extra to install. - **Scene:** Screen, Bubble (the camera in a round or square corner, small, medium or large), Side by side, or Camera, the camera mirrored if you like. The file stays as the camera saw it; the mirror is part of the edit. + Drag the bubble on the preview to move it to another corner. +- **Crop and colour** for the screen and the camera, like OBS's crop and + colour-correction filters: cut any edge away, change brightness, + contrast, saturation and warmth, and key out a green or blue screen behind + you. It shows live, fits every layout, and the take's clips start with it + (the files stay as recorded, so it can be undone or changed later). - **A file per source**, like OBS's Source Record: every take writes `screen.mp4`, `camera.mp4`, `microphone.m4a` and `system.m4a` into its own folder under `~/Movies/Splice/Recordings//`. All start at the same @@ -312,6 +318,8 @@ yourself and your screen. Nothing extra to install. hiss, hum) lowered, and the level evened out. Switch it on for the mic in Record, or for any clip under Sound in the inspector; the file stays as recorded. +- **Mic mode:** with a mic live, the Record tab opens macOS's own menu for + Voice Isolation, Wide Spectrum or Standard, and shows the one in use. - **Each project keeps its own setup.** "Make this my default" sets the one new projects start with. - **Permissions:** Screen Recording, Camera and Microphone, asked for the diff --git a/crates/splice-app/capture/main.swift b/crates/splice-app/capture/main.swift index aa26caf..8014e1b 100644 --- a/crates/splice-app/capture/main.swift +++ b/crates/splice-app/capture/main.swift @@ -35,6 +35,16 @@ func send(_ kind: UInt8, _ body: Data) { outLock.unlock() } +/// The mode macOS runs the microphone in for this app, as chosen in +/// Control Center. +func micMode() -> String { + switch AVCaptureDevice.activeMicrophoneMode { + case .voiceIsolation: return "voice-isolation" + case .wideSpectrum: return "wide-spectrum" + default: return "standard" + } +} + func event(_ name: String, _ fields: [String: Any] = [:]) { var all = fields all["event"] = name @@ -755,6 +765,7 @@ final class Capture: NSObject, SCStreamOutput, SCStreamDelegate, var fields: [String: Any] = [:] for (f, p) in peaks { fields[f.name] = Double(p) } if zero != nil { fields["seconds"] = seconds() } + if feed == .microphone { fields["mic_mode"] = micMode() } event("levels", fields) peaks = [:] } @@ -848,6 +859,9 @@ Thread.detachNewThread { capture.queue.async { capture.resume() } case "stop": capture.queue.async { capture.finish() } + case "micmodes": + // macOS's own menu: Standard, Voice Isolation, Wide Spectrum. + DispatchQueue.main.async { AVCaptureDevice.showSystemUserInterface(.microphoneModes) } case "quit": capture.shutdown() exit(0) diff --git a/crates/splice-app/src/capture.rs b/crates/splice-app/src/capture.rs index 4903ae9..e343412 100644 --- a/crates/splice-app/src/capture.rs +++ b/crates/splice-app/src/capture.rs @@ -11,6 +11,7 @@ use gpui::RenderImage; use serde::Deserialize; use serde_json::{Value, json}; use splice_core::studio::{Codec, Screen, Studio, Take}; +use splice_core::tone::Tone; use std::collections::HashMap; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -231,8 +232,14 @@ pub struct Live { /// The part of the camera's picture the scene shows; its preview frames /// arrive cut to it, so a bubble can be drawn round. pub camera_part: Arc>>, + /// Each picture's colour and key, worked on its preview frames as + /// they arrive. + pub tones: Arc>>, /// The loudest each sound has been lately, 0 to 1. pub levels: HashMap, + /// The microphone's mode in macOS: standard, voice-isolation or + /// wide-spectrum. + pub mic_mode: Option, /// Sources that have sent something. pub live: bool, pub recording: Option, @@ -269,6 +276,8 @@ impl Live { let (tx, messages) = channel(); let camera_part = Arc::new(std::sync::Mutex::new(None)); let wanted = camera_part.clone(); + let tones: Arc>> = Arc::default(); + let toned = tones.clone(); std::thread::Builder::new() .name("splice-capture".into()) .spawn(move || { @@ -296,10 +305,14 @@ impl Live { Feed::Camera => *wanted.lock().unwrap(), Feed::Screen => None, }; - let (pixels, size) = match part { + let (mut pixels, size) = match part { Some(part) => cut(&body, (w, h), part), None => (body, (w, h)), }; + let tone = toned.lock().unwrap().get(&feed).copied(); + if let Some(tone) = tone { + tone.apply(&mut pixels); + } match ffmpeg::render_image(size.0, size.1, pixels) { Some(image) => Message::Frame(feed, image, (w, h), part), None => continue, @@ -322,6 +335,8 @@ impl Live { natural: HashMap::new(), parts: HashMap::new(), camera_part, + tones, + mic_mode: None, levels: HashMap::new(), live: false, recording: None, @@ -359,6 +374,11 @@ impl Live { self.command(json!({ "cmd": "pause" })); } + /// Opens macOS's microphone modes (Voice Isolation and the rest). + pub fn mic_modes(&mut self) { + self.command(json!({ "cmd": "micmodes" })); + } + pub fn resume(&mut self) { self.command(json!({ "cmd": "resume" })); } @@ -437,6 +457,9 @@ impl Live { None => self.levels.remove(feed), }; } + if let Some(mode) = e["mic_mode"].as_str() { + self.mic_mode = Some(mode.to_string()); + } if let (Some(rec), Some(s)) = (&mut self.recording, e["seconds"].as_f64()) { rec.seconds = s; } diff --git a/crates/splice-app/src/ctl.rs b/crates/splice-app/src/ctl.rs index 024114f..e9bb0bb 100644 --- a/crates/splice-app/src/ctl.rs +++ b/crates/splice-app/src/ctl.rs @@ -208,6 +208,13 @@ Recording (each source to its own file; the take is laid out as the scene says) --clean/--no-clean (takes' mic clips come in with the voice cleaned up) --codec h264|hevc --quality good|high|best --fps 30|60 --height full|4k|1440|1080 --cursor | --no-cursor --clicks | --no-clicks (note clicks, to zoom in on later) + record adjust screen|camera [LOOK] [--reset] + change a picture as recorded clips will: --crop-left/-right/-top/ + -bottom F, colour (--exposure …), --key green, --blur F, --redact … + (any look option but place and size, which the scene sets); shown + live, and each take's clip starts with it (the file is untouched) + record mic-mode open macOS's Mic Mode menu (Voice Isolation, Wide Spectrum); the + mode in use shows in `record status` as mic_mode record start [SETUP] [--for S] [--beat B | --at T [--track N] | --into media] start (answers once recording); the take goes after everything on the timeline, into beat B, at T, or only into the media @@ -216,7 +223,8 @@ Recording (each source to its own file; the take is laid out as the scene says) record status recording or not, for how long, sound levels, the last take's folder Operating the window (as a person would: for checking the interface) - ui window size and scale, mouse, stage, timeline and picture bounds + ui window size and scale, mouse, stage, timeline and picture bounds, + and in Record the scene and the camera bubble click X Y [--double] [--right] [--shift|--cmd|--alt|--ctrl] hover X Y move the pointer there (tooltips show after a moment) drag X1 Y1 X2 Y2 [--shift…] press, move, release @@ -706,6 +714,10 @@ fn build(args: &[String]) -> Result { if let Some(action) = words.get(1).filter(|_| sub == "shot" || sub == "take") { request.insert("action".into(), json!(action)); } + if command == "record" && sub == "adjust" { + let source = words.get(1).ok_or("record adjust expects screen or camera")?; + request.insert("source".into(), json!(source)); + } command.as_str() } "view" => { @@ -848,6 +860,20 @@ mod tests { r, json!({"cmd": "text", "text": "Hi", "size": 96.0, "bold": true, "duration": 2.5}) ); + let r = build(&args( + "record adjust camera --crop-left 0.2 --exposure 0.5 --key green --reset", + )) + .unwrap(); + assert_eq!( + r, + json!({"cmd": "record", "sub": "adjust", "source": "camera", "crop_left": 0.2, "exposure": 0.5, + "key": "green", "reset": true}) + ); + assert!(build(&args("record adjust")).is_err(), "which picture"); + assert_eq!( + build(&args("record mic-mode")).unwrap(), + json!({"cmd": "record", "sub": "mic-mode"}) + ); assert!(build(&args("move --clip x")).is_err()); assert!(build(&args("explode")).is_err()); let r = build(&args( diff --git a/crates/splice-app/src/editor/agent.rs b/crates/splice-app/src/editor/agent.rs index 8dcba36..3bb8814 100644 --- a/crates/splice-app/src/editor/agent.rs +++ b/crates/splice-app/src/editor/agent.rs @@ -88,7 +88,7 @@ pub fn parse_color(text: &str) -> Option { } } -fn look_json(look: &splice_core::look::Look) -> Value { +pub(super) fn look_json(look: &splice_core::look::Look) -> Value { json!({ "x": look.x, "y": look.y, @@ -1139,7 +1139,7 @@ impl Editor { /// Look fields from a request, onto `look`. /// `at` is clip time, where a keyframed value is keyed. -fn apply_look(look: &mut splice_core::look::Look, request: &Value, at: Time) -> Result<(), String> { +pub(super) fn apply_look(look: &mut splice_core::look::Look, request: &Value, at: Time) -> Result<(), String> { use splice_core::look::{Drift, Key, Mask, MotionKind, Shape}; let text = |key: &str| request.get(key).and_then(Value::as_str); let number = |key: &str| request.get(key).and_then(Value::as_f64).map(|v| v as f32); diff --git a/crates/splice-app/src/editor/input.rs b/crates/splice-app/src/editor/input.rs index 2bba300..645741c 100644 --- a/crates/splice-app/src/editor/input.rs +++ b/crates/splice-app/src/editor/input.rs @@ -90,6 +90,10 @@ impl Editor { "pictures": layers, "overlay": !matches!(self.overlay, Overlay::None), "editor": self.workbench.as_ref().map(|b| b.media.0), + "record": (self.mode == story::Mode::Record).then(|| { + let (scene, bubble) = self.rec.drawn; + json!({ "scene": scene.map(rect), "bubble": bubble.map(rect) }) + }), "note": "Positions are window points: a screenshot's pixels divided by `scale`.", })); } diff --git a/crates/splice-app/src/editor/record.rs b/crates/splice-app/src/editor/record.rs index 95b1e25..ae777fd 100644 --- a/crates/splice-app/src/editor/record.rs +++ b/crates/splice-app/src/editor/record.rs @@ -11,7 +11,7 @@ use crate::capture::{self, Devices, Feed, Happened, Live}; use crate::recorder; use gpui::FontWeight; use serde_json::{Value, json}; -use splice_core::look::Look; +use splice_core::look::{Key, Look}; use splice_core::story::BeatId; use splice_core::studio::{self, Bubble, Codec, Corner, Layout, Quality, Screen, Studio, Take}; use std::sync::mpsc::Sender; @@ -60,6 +60,121 @@ pub struct Recorder { problem: Option, /// Where the stage is drawn, measured as it is painted. bounds: Rc>>, + /// The camera bubble being dragged: where the pointer went down and + /// where it is now. + drag: Option<(Point, Point)>, + /// The corner a dragged bubble would land in. + landing: Option, + /// Where the scene and the camera bubble were last drawn, in window + /// points, for `ctl ui`. + pub drawn: (Option>, Option>), + /// The picture whose crop and colour are open in the sources panel. + adjusting: Option, + /// A slider being dragged, and the project before the drag (one undo). + sliding: Option<(Feed, Knob, Project)>, + /// Where each slider's track was drawn. + tracks: Tracks, +} + +/// Where each slider's track was drawn, measured as it is painted. +type Tracks = Rc>>>; + +/// What a slider in a picture's crop and colour changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Knob { + Left, + Right, + Top, + Bottom, + Exposure, + Contrast, + Saturation, + Warmth, + /// How far from the key colour still counts as screen. + Reach, +} + +impl Knob { + const CROP: [Knob; 4] = [Knob::Left, Knob::Right, Knob::Top, Knob::Bottom]; + const COLOUR: [Knob; 4] = [Knob::Exposure, Knob::Contrast, Knob::Saturation, Knob::Warmth]; + + fn label(self) -> &'static str { + match self { + Knob::Left => "Left", + Knob::Right => "Right", + Knob::Top => "Top", + Knob::Bottom => "Bottom", + Knob::Exposure => "Brightness", + Knob::Contrast => "Contrast", + Knob::Saturation => "Saturation", + Knob::Warmth => "Warmth", + Knob::Reach => "Reach", + } + } + + fn range(self) -> (f32, f32) { + match self { + Knob::Left | Knob::Right | Knob::Top | Knob::Bottom => (0.0, 0.45), + Knob::Reach => (0.02, 0.45), + _ => (-1.0, 1.0), + } + } + + /// Where a double click puts it back. + fn rest(self) -> f32 { + match self { + Knob::Reach => Key::default().similarity, + _ => 0.0, + } + } + + fn get(self, look: &Look) -> f32 { + let crop = look.crop.unwrap_or_default(); + match self { + Knob::Left => crop.left, + Knob::Right => crop.right, + Knob::Top => crop.top, + Knob::Bottom => crop.bottom, + Knob::Exposure => look.grade.exposure, + Knob::Contrast => look.grade.contrast, + Knob::Saturation => look.grade.saturation, + Knob::Warmth => look.grade.temperature, + Knob::Reach => look.key.map_or(Key::default().similarity, |k| k.similarity), + } + } + + fn set(self, look: &mut Look, v: f32) { + let (lo, hi) = self.range(); + let v = v.clamp(lo, hi); + let mut crop = look.crop.unwrap_or_default(); + match self { + Knob::Left => crop.left = v, + Knob::Right => crop.right = v, + Knob::Top => crop.top = v, + Knob::Bottom => crop.bottom = v, + Knob::Exposure => look.grade.exposure = v, + Knob::Contrast => look.grade.contrast = v, + Knob::Saturation => look.grade.saturation = v, + Knob::Warmth => look.grade.temperature = v, + Knob::Reach => { + if let Some(key) = &mut look.key { + key.similarity = v; + } + } + } + if Knob::CROP.contains(&self) { + look.crop = Some(crop.clamped()).filter(|c| !c.is_none()); + } + } + + fn shown(self, v: f32) -> String { + match self { + Knob::Left | Knob::Right | Knob::Top | Knob::Bottom => format!("{:.0}%", v * 100.0), + Knob::Reach => format!("{:.2}", v), + _ if v.abs() < 0.005 => "0".into(), + _ => format!("{v:+.2}"), + } + } } impl Recorder { @@ -144,6 +259,20 @@ impl Editor { self.relive(cx); } + /// Lets go of a dragged bubble, in the corner it was over. + fn drop_bubble(&mut self, cx: &mut Context) { + let Some((from, to)) = self.rec.drag.take() else { + return; + }; + let moved = f32::from(to.x - from.x).hypot(f32::from(to.y - from.y)); + match self.rec.landing.take() { + Some(corner) if moved > 3.0 && corner != self.studio().scene.corner => { + self.set_studio(cx, |s| s.scene.corner = corner) + } + _ => cx.notify(), + } + } + fn list_devices(&mut self, cx: &mut Context) { if self.rec.listing { return; @@ -681,16 +810,127 @@ impl Editor { // Camera frames come cut to what the scene shows of them. if let Some(live) = live { *live.camera_part.lock().unwrap() = shown.camera.as_ref().map(|l| shown_part(l).0); + *live.tones.lock().unwrap() = [(Feed::Screen, &shown.screen), (Feed::Camera, &shown.camera)] + .into_iter() + .filter_map(|(feed, look)| { + let look = look.as_ref()?; + let tone = splice_core::tone::Tone { + grade: look.grade, + key: look.key, + }; + (!tone.is_none()).then_some((feed, tone)) + }) + .collect(); } + // A bubble over the screen can be dragged to another corner: it + // follows the pointer, a ring shows where it will land, and letting + // go puts it there. + let bubble = match (&shown.screen, &shown.camera, dims(Feed::Camera)) { + (Some(_), Some(look), Some(natural)) if look.mask.is_some() => Some(shown_rect(natural, look, (w, h))), + _ => None, + }; + let moved = self.rec.drag.map_or((0.0, 0.0), |(from, now)| { + (f32::from(now.x - from.x), f32::from(now.y - from.y)) + }); + let landing = bubble.filter(|_| self.rec.drag.is_some()).map(|[l, t, bw, bh]| { + let (x, y) = (l + bw / 2.0 + moved.0, t + bh / 2.0 + moved.1); + let corner = corner_at(x > w / 2.0, y > h / 2.0); + let mut there = studio.clone(); + there.scene.corner = corner; + let rect = there + .looks(canvas_px, dims(Feed::Screen), dims(Feed::Camera)) + .camera + .zip(dims(Feed::Camera)) + .map(|(look, natural)| shown_rect(natural, &look, (w, h))); + (corner, rect) + }); let mut layers: Vec = Vec::new(); for (feed, look) in [(Feed::Screen, shown.screen), (Feed::Camera, shown.camera)] { - let (Some(look), Some(image)) = (look, frames.get(&feed)) else { + let (Some(mut look), Some(image)) = (look, frames.get(&feed)) else { continue; }; let Some(natural) = dims(feed) else { continue }; + if feed == Feed::Camera && bubble.is_some() { + look.x += moved.0 / w; + look.y += moved.1 / h; + } let part = live.and_then(|l| l.parts.get(&feed).copied().flatten()); layers.push(picture(image.clone(), natural, part, &look, (w, h))); } + let round = studio.scene.shape == Bubble::Circle; + if let Some((_, Some([l, t, bw, bh]))) = landing { + layers.push( + div() + .absolute() + .left(px(l)) + .top(px(t)) + .w(px(bw)) + .h(px(bh)) + .border_2() + .border_color(gpui::rgba(0xffffffb3)) + .when(round, |d| d.rounded_full()) + .into_any_element(), + ); + } + if let Some([l, t, bw, bh]) = bubble { + let dragging = self.rec.drag.is_some(); + layers.push( + div() + .id("bubble-grip") + .absolute() + .left(px(l + moved.0)) + .top(px(t + moved.1)) + .w(px(bw)) + .h(px(bh)) + .when(round, |d| d.rounded_full()) + .cursor_grab() + .when(!dragging, |d| d.tooltip(tip("Drag to another corner", None))) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _, cx| { + this.rec.drag = Some((event.position, event.position)); + cx.stop_propagation(); + cx.notify(); + }), + ) + .into_any_element(), + ); + } + // While dragging, the pointer is followed anywhere in the window. + let entity = cx.entity(); + let follow = self.rec.drag.is_some().then(|| { + canvas( + |_, _, _| {}, + move |_, _, window, _| { + let e = entity.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { + if phase.bubble() { + e.update(cx, |editor, cx| { + if event.pressed_button != Some(MouseButton::Left) { + editor.drop_bubble(cx); + } else if let Some(drag) = editor.rec.drag.as_mut() { + drag.1 = event.position; + cx.notify(); + } + }); + } + }); + let e = entity; + window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| { + if phase.bubble() { + e.update(cx, |editor, cx| editor.drop_bubble(cx)); + } + }); + }, + ) + .absolute() + .size_full() + }); + self.rec.landing = landing.map(|(corner, _)| corner); + let at = |x: f32, y: f32, w: f32, h: f32| { + Bounds::new(area.origin + point(px(left + x), px(top + y)), size(px(w), px(h))) + }; + self.rec.drawn = (Some(at(0.0, 0.0, w, h)), bubble.map(|[l, t, bw, bh]| at(l, t, bw, bh))); let failed = self.rec.problem.is_some(); let status = if let Some(problem) = &self.rec.problem { Some(problem.clone()) @@ -708,6 +948,7 @@ impl Editor { .min_h_0() .relative() .child(measure) + .children(follow) .child( div() .absolute() @@ -905,8 +1146,336 @@ impl Editor { }) } + /// A picture's crop, colour and (for a camera) green screen: shown + /// live, and each take's clip starts with them. + fn adjust_panel(&mut self, studio: &Studio, feed: Feed, cx: &mut Context) -> AnyElement { + let look = own_look(studio, feed).clone(); + let open = self.rec.adjusting == Some(feed); + let mut said = Vec::new(); + if look.crop.is_some() { + said.push("cropped"); + } + if !look.grade.is_none() { + said.push("colour"); + } + if look.key.is_some() { + said.push("green screen"); + } + if said.is_empty() && look != Look::default() { + said.push("changed"); + } + let id = |name: &str| SharedString::from(format!("{name}-{feed:?}")); + let header = div() + .id(id("adjust")) + .h(px(26.0)) + .ml(px(23.0)) + .px(px(8.0)) + .rounded(px(7.0)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .hover(|d| d.bg(Theme::surface0())) + .child( + div() + .flex_none() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child("Crop and colour"), + ) + .when(!said.is_empty(), |d| { + d.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_right() + .text_size(px(11.0)) + .text_color(Theme::overlay()) + .child(said.join(" · ")), + ) + }) + .child(icon(if open { "chevron" } else { "chevron-right" }, Theme::overlay()).size(px(11.0))) + .on_click(cx.listener(move |this, _, _, cx| { + this.rec.adjusting = if this.rec.adjusting == Some(feed) { + None + } else { + Some(feed) + }; + cx.notify(); + })); + let mut body: Vec = Vec::new(); + if open { + let heading = |text: &'static str| { + div() + .pt(px(6.0)) + .text_size(px(10.5)) + .text_color(Theme::overlay()) + .child(text) + .into_any_element() + }; + body.push(heading("CROP")); + for knob in Knob::CROP { + body.push(self.knob(feed, knob, &look, cx)); + } + body.push(heading("COLOUR")); + for knob in Knob::COLOUR { + body.push(self.knob(feed, knob, &look, cx)); + } + if feed == Feed::Camera { + let keyed = look.key.is_some(); + body.push( + div() + .id(id("key")) + .h(px(26.0)) + .mt(px(4.0)) + .flex() + .items_center() + .cursor_pointer() + .child( + div() + .flex_1() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child("Green screen"), + ) + .child(switch(keyed)) + .tooltip(tip( + "The screen behind you turns clear, showing what is under the camera", + None, + )) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_studio(cx, |s| { + s.camera_look.key = if s.camera_look.key.is_some() { + None + } else { + Some(Key::default()) + }; + }) + })) + .into_any_element(), + ); + if let Some(key) = look.key { + let colours = [(0x00ff00u32, "Green"), (0x0000ffu32, "Blue")].map(|(colour, name)| { + chip((id("key-colour"), colour as usize), name, key.color == colour) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_studio(cx, |s| { + if let Some(k) = &mut s.camera_look.key { + k.color = colour; + } + }) + })) + .into_any_element() + }); + body.push(div().flex().gap(px(6.0)).children(colours).into_any_element()); + body.push(self.knob(feed, Knob::Reach, &look, cx)); + } + } + if look != Look::default() { + body.push( + div() + .pt(px(6.0)) + .flex() + .child( + chip(id("adjust-reset"), "Reset", false) + .tooltip(tip("Back to the picture as it is", None)) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_studio(cx, |s| *own_look_mut(s, feed) = Look::default()) + })), + ) + .into_any_element(), + ); + } + } + // While a slider is held, the pointer is followed anywhere. + let entity = cx.entity(); + let follow = self.rec.sliding.as_ref().filter(|(f, ..)| *f == feed).map(|_| { + canvas( + |_, _, _| {}, + move |_, _, window, _| { + let e = entity.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { + if phase.bubble() { + e.update(cx, |editor, cx| { + if event.pressed_button == Some(MouseButton::Left) { + editor.slide_knob(event.position, cx); + } else { + editor.let_go_knob(cx); + } + }); + } + }); + let e = entity; + window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| { + if phase.bubble() { + e.update(cx, |editor, cx| editor.let_go_knob(cx)); + } + }); + }, + ) + .absolute() + .size_full() + }); + div() + .relative() + .flex() + .flex_col() + .child(header) + .when(open, |d| { + d.child( + div() + .ml(px(31.0)) + .mr(px(8.0)) + .pb(px(6.0)) + .flex() + .flex_col() + .children(body), + ) + }) + .children(follow) + .into_any_element() + } + + /// One slider of a picture's crop and colour. + fn knob(&self, feed: Feed, knob: Knob, look: &Look, cx: &mut Context) -> AnyElement { + let (lo, hi) = knob.range(); + let value = knob.get(look); + let frac = ((value - lo) / (hi - lo)).clamp(0.0, 1.0); + let held = self + .rec + .sliding + .as_ref() + .is_some_and(|(f, k, _)| *f == feed && *k == knob); + let tracks = self.rec.tracks.clone(); + let group = SharedString::from(format!("knob-{feed:?}-{knob:?}")); + // Two-sided adjustments fill from the middle. + let from = if lo < 0.0 { + (-lo / (hi - lo)).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (a, b) = if frac < from { (frac, from) } else { (from, frac) }; + div() + .h(px(22.0)) + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .w(px(62.0)) + .flex_none() + .text_size(px(11.0)) + .text_color(Theme::subtext()) + .child(knob.label()), + ) + .child( + div() + .id(group.clone()) + .group(group.clone()) + .flex_1() + .h(px(18.0)) + .relative() + .cursor_pointer() + .child( + canvas( + move |b, _, _| { + tracks.borrow_mut().insert((feed, knob), b); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .left_0() + .right_0() + .top(px(8.0)) + .h(px(2.0)) + .rounded_full() + .bg(Theme::surface1()), + ) + .child( + div() + .absolute() + .left(relative(a)) + .w(relative(b - a)) + .top(px(8.0)) + .h(px(2.0)) + .rounded_full() + .bg(Theme::subtext()), + ) + .child( + div() + .absolute() + .top(px(3.0)) + .left(relative(frac)) + .ml(px(-6.0)) + .size(px(12.0)) + .rounded_full() + .bg(Theme::text()) + .border_2() + .border_color(if held { Theme::accent() } else { Theme::panel() }) + .group_hover(group, |s| s.border_color(Theme::accent())), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _, cx| { + cx.stop_propagation(); + if event.click_count >= 2 { + this.set_studio(cx, |s| knob.set(own_look_mut(s, feed), knob.rest())); + return; + } + // Editing a project still on the default setup + // gives it its own, as any change here does. + let studio = this.studio(); + this.doc.project.studio.get_or_insert(studio); + this.rec.sliding = Some((feed, knob, this.doc.project.clone())); + this.slide_knob(event.position, cx); + }), + ), + ) + .child( + div() + .w(px(38.0)) + .flex_none() + .text_right() + .text_size(px(11.0)) + .text_color(Theme::overlay()) + .child(knob.shown(value)), + ) + .into_any_element() + } + + /// The held slider follows the pointer, changing the picture live. + fn slide_knob(&mut self, at: Point, cx: &mut Context) { + let Some((feed, knob, _)) = self.rec.sliding.as_ref().map(|(f, k, _)| (*f, *k, ())) else { + return; + }; + let Some(track) = self.rec.tracks.borrow().get(&(feed, knob)).copied() else { + return; + }; + let frac = (f32::from(at.x - track.origin.x) / f32::from(track.size.width).max(1.0)).clamp(0.0, 1.0); + let (lo, hi) = knob.range(); + if let Some(studio) = &mut self.doc.project.studio { + knob.set(own_look_mut(studio, feed), lo + frac * (hi - lo)); + } + cx.notify(); + } + + /// The slider let go: one undo step for the whole drag. + fn let_go_knob(&mut self, cx: &mut Context) { + if let Some((_, _, before)) = self.rec.sliding.take() { + self.doc.commit(before); + self.after_edit(); + cx.notify(); + } + } + fn sources_panel(&mut self, studio: &Studio, cx: &mut Context) -> impl IntoElement { let levels = self.rec.live.as_ref().map(|l| l.levels.clone()).unwrap_or_default(); + let mic_mode = self.rec.live.as_ref().and_then(|l| l.mic_mode.clone()); let devices = self.rec.devices.clone().unwrap_or_default(); let open = self.rec.pick; let row = |id: &'static str, @@ -1097,43 +1666,91 @@ impl Editor { if open == Some(Pick::Screen) { list.extend(options(Pick::Screen, cx)); } + if studio.screen.is_some() { + list.push(self.adjust_panel(studio, Feed::Screen, cx)); + } list.push(camera_row.into_any_element()); if open == Some(Pick::Camera) { list.extend(options(Pick::Camera, cx)); } + if studio.camera.is_some() { + list.push(self.adjust_panel(studio, Feed::Camera, cx)); + } list.push( div() .flex() .flex_col() .child(mic_row) .when(studio.microphone.is_some(), |d| { - d.child(meter(levels.get("microphone").copied())).child( - div() - .id("mic-clean") - .h(px(28.0)) - .px(px(8.0)) - .ml(px(23.0)) - .rounded(px(7.0)) - .flex() - .items_center() - .gap(px(9.0)) - .cursor_pointer() - .hover(|d| d.bg(Theme::surface0())) - .child( + d.child(meter(levels.get("microphone").copied())) + .child( + div() + .id("mic-clean") + .h(px(28.0)) + .px(px(8.0)) + .ml(px(23.0)) + .rounded(px(7.0)) + .flex() + .items_center() + .gap(px(9.0)) + .cursor_pointer() + .hover(|d| d.bg(Theme::surface0())) + .child( + div() + .flex_1() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child("Clean up the voice"), + ) + .child(switch(studio.clean)) + .tooltip(tip( + "Takes come in with rumble and steady noise lowered and the level evened out; \ + microphone.m4a stays as recorded", + None, + )) + .on_click(cx.listener(|this, _, _, cx| this.set_studio(cx, |s| s.clean = !s.clean))), + ) + .when_some(mic_mode.clone(), |d, mode| { + d.child( div() - .flex_1() - .text_size(px(11.5)) - .text_color(Theme::subtext()) - .child("Clean up the voice"), + .id("mic-mode") + .h(px(28.0)) + .px(px(8.0)) + .ml(px(23.0)) + .rounded(px(7.0)) + .flex() + .items_center() + .gap(px(9.0)) + .cursor_pointer() + .hover(|d| d.bg(Theme::surface0())) + .child( + div() + .flex_1() + .text_size(px(11.5)) + .text_color(Theme::subtext()) + .child("Mic mode"), + ) + .child(div().text_size(px(11.5)).text_color(Theme::text()).child( + match mode.as_str() { + "voice-isolation" => "Voice Isolation", + "wide-spectrum" => "Wide Spectrum", + _ => "Standard", + }, + )) + .child(icon("chevron-right", Theme::overlay()).size(px(11.0))) + .tooltip(tip( + "macOS's own processing of the microphone: Voice Isolation keeps your voice \ + and drops what's around it, before Splice gets the sound", + None, + )) + .on_click(cx.listener(|this, _, _, cx| { + if let Some(live) = &mut this.rec.live { + live.mic_modes(); + } + cx.notify(); + })), ) - .child(switch(studio.clean)) - .tooltip(tip( - "Takes come in with rumble and steady noise lowered and the level evened out; \ - microphone.m4a stays as recorded", - None, - )) - .on_click(cx.listener(|this, _, _, cx| this.set_studio(cx, |s| s.clean = !s.clean))), - ) + }) }) .into_any_element(), ); @@ -1356,6 +1973,43 @@ impl Editor { "own": self.doc.project.studio.is_some(), })) } + "mic-mode" => { + let Some(live) = self.rec.live.as_mut().filter(|l| l.mic_mode.is_some()) else { + return Some(fail("the microphone isn't live: open the Record tab with a mic chosen")); + }; + live.mic_modes(); + Some(json!({ + "ok": true, + "mode": live.mic_mode, + "note": "macOS's Mic Mode menu is open: Voice Isolation, Wide Spectrum or Standard", + })) + } + "adjust" => { + let source = request.get("source").and_then(Value::as_str).unwrap_or_default(); + let camera = match source { + "camera" => true, + "screen" => false, + _ => return Some(fail("record adjust expects screen or camera")), + }; + let mut studio = self.studio(); + let look = if camera { + &mut studio.camera_look + } else { + &mut studio.screen_look + }; + if request.get("reset").and_then(Value::as_bool) == Some(true) { + *look = Look::default(); + } + if let Err(e) = agent::apply_look(look, request, 0) { + return Some(fail(e)); + } + // Its place and size are the scene's. + let shown = look.clone(); + if studio != self.studio() { + self.set_studio(cx, |s| *s = studio); + } + Some(json!({ "ok": true, "source": source, "look": agent::look_json(&shown) })) + } "status" => Some(json!({ "ok": true, "recording": self.rec.recording(), @@ -1363,6 +2017,7 @@ impl Editor { "seconds": self.rec.seconds(), "live": self.rec.live.as_ref().is_some_and(|l| l.live), "levels": self.rec.live.as_ref().map(|l| l.levels.clone()), + "mic_mode": self.rec.live.as_ref().and_then(|l| l.mic_mode.clone()), "problem": self.rec.problem, "asking": self.rec.live.as_ref().and_then(|l| l.asking.clone()), "last": self.rec.last, @@ -1627,21 +2282,52 @@ fn take_name(path: &Path) -> String { /// What shows of a picture: all of it, a mask's box, or what a crop /// leaves (as a `Part`), and whether it is round. fn shown_part(look: &Look) -> (capture::Part, bool) { - if let Some(m) = look.mask { - let part = [ - m.x - m.width / 2.0, - m.y - m.height / 2.0, - m.x + m.width / 2.0, - m.y + m.height / 2.0, - ]; - ( - part.map(|v| v.clamp(0.0, 1.0)), - m.shape == splice_core::look::Shape::Ellipse, - ) - } else if let Some(c) = look.crop { - ([c.left, c.top, 1.0 - c.right, 1.0 - c.bottom], false) - } else { - ([0.0, 0.0, 1.0, 1.0], false) + // A mask is measured on what the crop leaves. + let c = look.crop.unwrap_or_default(); + let [l, t, r, b] = [c.left, c.top, 1.0 - c.right, 1.0 - c.bottom]; + match look.mask { + Some(m) => { + let (kw, kh) = (r - l, b - t); + let part = [ + l + (m.x - m.width / 2.0) * kw, + t + (m.y - m.height / 2.0) * kh, + l + (m.x + m.width / 2.0) * kw, + t + (m.y + m.height / 2.0) * kh, + ]; + ( + part.map(|v| v.clamp(0.0, 1.0)), + m.shape == splice_core::look::Shape::Ellipse, + ) + } + None => ([l, t, r, b], false), + } +} + +/// Where `look` puts the whole of a picture on a stage of `stage` points: +/// left, top, width and height. +fn placed_rect(natural: (u32, u32), look: &Look, stage: (f32, f32)) -> [f32; 4] { + let (sw, sh) = stage; + let (nw, nh) = (natural.0.max(1) as f32, natural.1.max(1) as f32); + let fit = (sw / nw).min(sh / nh) * look.scale; + let (pw, ph) = (nw * fit, nh * fit); + let (cx, cy) = (sw / 2.0 + look.x * sw, sh / 2.0 + look.y * sh); + [cx - pw / 2.0, cy - ph / 2.0, pw, ph] +} + +/// Where the part of a picture that shows lands on the stage. +fn shown_rect(natural: (u32, u32), look: &Look, stage: (f32, f32)) -> [f32; 4] { + let [pl, pt, pw, ph] = placed_rect(natural, look, stage); + let ([l, t, r, b], _) = shown_part(look); + [pl + l * pw, pt + t * ph, (r - l) * pw, (b - t) * ph] +} + +/// The corner on the right or left, at the bottom or top. +fn corner_at(right: bool, bottom: bool) -> Corner { + match (right, bottom) { + (false, false) => Corner::TopLeft, + (true, false) => Corner::TopRight, + (false, true) => Corner::BottomLeft, + (true, true) => Corner::BottomRight, } } @@ -1654,14 +2340,9 @@ fn picture( look: &Look, stage: (f32, f32), ) -> AnyElement { - let (sw, sh) = stage; - let (nw, nh) = (natural.0.max(1) as f32, natural.1.max(1) as f32); - let fit = (sw / nw).min(sh / nh) * look.scale; - let (pw, ph) = (nw * fit, nh * fit); - let (cx, cy) = (sw / 2.0 + look.x * sw, sh / 2.0 + look.y * sh); - let (pl, pt) = (cx - pw / 2.0, cy - ph / 2.0); + let [pl, pt, pw, ph] = placed_rect(natural, look, stage); let ([l, t, r, b], round) = shown_part(look); - let (vl, vt, vw, vh) = (pl + l * pw, pt + t * ph, (r - l) * pw, (b - t) * ph); + let [vl, vt, vw, vh] = shown_rect(natural, look, stage); // A frame holding just what shows is drawn as it is, round if need be: // clipping a larger one to a circle is beyond gpui. if part.is_some_and(|p| p.iter().zip([l, t, r, b]).all(|(a, b)| (a - b).abs() < 1e-3)) { @@ -1695,6 +2376,21 @@ fn picture( .into_any_element() } +/// The look a picture's takes start with. +fn own_look(studio: &Studio, feed: Feed) -> &Look { + match feed { + Feed::Screen => &studio.screen_look, + Feed::Camera => &studio.camera_look, + } +} + +fn own_look_mut(studio: &mut Studio, feed: Feed) -> &mut Look { + match feed { + Feed::Screen => &mut studio.screen_look, + Feed::Camera => &mut studio.camera_look, + } +} + /// A small choice that is on or off. fn chip(id: impl std::convert::Into, label: &str, on: bool) -> gpui::Stateful { div() diff --git a/crates/splice-app/src/ffmpeg.rs b/crates/splice-app/src/ffmpeg.rs index 187c60c..5bce3e2 100644 --- a/crates/splice-app/src/ffmpeg.rs +++ b/crates/splice-app/src/ffmpeg.rs @@ -351,10 +351,11 @@ pub fn decode_audio_retimed( filters.push(format!("atempo={rest:.6}")); } let mut command = base_command()?; - if from > 0 { - command.args(["-ss", &format!("{:.6}", secs(from))]); - } + // Always seeked, even to 0, as export does: the clean-up's filters + // adapt from their first sample, and AAC's priming samples are only + // dropped when seeking, so starting differently changes the level. command + .args(["-ss", &format!("{:.6}", secs(from.max(0)))]) .args(["-t", &format!("{:.6}", secs(length))]) .arg("-i") .arg(path); diff --git a/crates/splice-core/src/lib.rs b/crates/splice-core/src/lib.rs index 9591b1f..2144c70 100644 --- a/crates/splice-core/src/lib.rs +++ b/crates/splice-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod studio; pub mod symbol; pub mod textmotion; pub mod time; +pub mod tone; pub mod transition; pub mod voice; pub mod zoom; diff --git a/crates/splice-core/src/studio.rs b/crates/splice-core/src/studio.rs index a35d420..36eeef3 100644 --- a/crates/splice-core/src/studio.rs +++ b/crates/splice-core/src/studio.rs @@ -22,9 +22,77 @@ pub struct Studio { /// file stays as recorded). pub clean: bool, pub scene: Scene, + /// How each picture is changed, as a clip's look: crop, colour, a + /// green-screen key, effects, a hidden box. Its place and size come + /// from the scene. Takes' clips start with it (the files stay as + /// recorded), and the preview shows it live. + #[serde(skip_serializing_if = "untouched")] + pub screen_look: Look, + #[serde(skip_serializing_if = "untouched")] + pub camera_look: Look, pub output: Output, } +fn untouched(look: &Look) -> bool { + *look == Look::default() +} + +/// The edges a look cuts away, if any. +fn cut_of(look: &Look) -> Option { + look.crop.map(Crop::clamped).filter(|c| !c.is_none()) +} + +/// A picture's size once `look` has cut its edges away. +fn cut_size(size: (u32, u32), look: &Look) -> (u32, u32) { + match cut_of(look) { + Some(c) => { + let (w, h) = c.size((size.0.max(1) as f64, size.1.max(1) as f64)); + ((w.round() as u32).max(1), (h.round() as u32).max(1)) + } + None => size, + } +} + +/// `placed`, where the scene put a picture of the cut size, as a look on +/// the whole picture: its own crop added to any the layout made, and the +/// rest of `own` (colour, key, effects) kept. +fn on_whole(placed: Look, own: &Look, size: (u32, u32), canvas: (u32, u32)) -> Look { + let mut look = Look { + x: placed.x, + y: placed.y, + scale: placed.scale, + mask: placed.mask, + crop: placed.crop, + mirror: placed.mirror || own.mirror, + keys: Default::default(), + ..own.clone() + }; + let Some(c) = cut_of(own) else { + return look; + }; + let (w, h) = (canvas.0.max(1) as f32, canvas.1.max(1) as f32); + let fit = |s: (u32, u32)| (w / s.0.max(1) as f32).min(h / s.1.max(1) as f32); + let cut = cut_size(size, own); + // Canvas pixels per picture pixel, as the layout sized the cut picture. + let k = fit(cut) * placed.scale; + look.scale = k / fit(size); + // The whole picture's centre sits off the kept part's centre. + let (fw, fh) = (size.0.max(1) as f32, size.1.max(1) as f32); + look.x += (0.5 - (c.left + 1.0 - c.right) / 2.0) * fw * k / w; + look.y += (0.5 - (c.top + 1.0 - c.bottom) / 2.0) * fh * k / h; + // A layout's crop is of the kept part; a mask is measured on what the + // crop leaves, which is the kept part already. + let (kw, kh) = (1.0 - c.left - c.right, 1.0 - c.top - c.bottom); + let more = placed.crop.unwrap_or_default(); + look.crop = Some(Crop { + left: c.left + more.left * kw, + right: c.right + more.right * kw, + top: c.top + more.top * kh, + bottom: c.bottom + more.bottom * kh, + }); + look +} + /// What of the screen is recorded. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] @@ -255,6 +323,25 @@ impl Studio { /// the screen's and the camera's, given their sizes. None leaves that /// picture off the timeline (it is still recorded, and in the media). pub fn looks(&self, canvas: (u32, u32), screen: Option<(u32, u32)>, camera: Option<(u32, u32)>) -> Placed { + // Laid out as if each picture were only what its crop keeps. + let placed = self.layout( + canvas, + screen.map(|s| cut_size(s, &self.screen_look)), + camera.map(|c| cut_size(c, &self.camera_look)), + ); + Placed { + screen: placed + .screen + .zip(screen) + .map(|(p, size)| on_whole(p, &self.screen_look, size, canvas)), + camera: placed + .camera + .zip(camera) + .map(|(p, size)| on_whole(p, &self.camera_look, size, canvas)), + } + } + + fn layout(&self, canvas: (u32, u32), screen: Option<(u32, u32)>, camera: Option<(u32, u32)>) -> Placed { let scene = self.scene; let (w, h) = (canvas.0.max(1) as f32, canvas.1.max(1) as f32); // Scale 1 is the picture fitted inside the canvas. @@ -479,6 +566,73 @@ mod tests { assert!(placed.camera.is_none(), "recorded, in the media, not laid out"); } + /// Where the part of a picture that shows lands on the canvas: left, + /// top, right, bottom in pixels. + fn shown(look: &Look, size: (u32, u32)) -> [f32; 4] { + let (w, h) = (CANVAS.0 as f32, CANVAS.1 as f32); + let k = (w / size.0 as f32).min(h / size.1 as f32) * look.scale; + let (pw, ph) = (size.0 as f32 * k, size.1 as f32 * k); + let (left, top) = (w / 2.0 + look.x * w - pw / 2.0, h / 2.0 + look.y * h - ph / 2.0); + let c = look.crop.unwrap_or_default(); + let [mut l, mut t, mut r, mut b] = [c.left, c.top, 1.0 - c.right, 1.0 - c.bottom]; + if let Some(m) = look.mask { + let (kw, kh) = (r - l, b - t); + [l, t, r, b] = [ + l + (m.x - m.width / 2.0) * kw, + t + (m.y - m.height / 2.0) * kh, + l + (m.x + m.width / 2.0) * kw, + t + (m.y + m.height / 2.0) * kh, + ]; + } + [left + l * pw, top + t * ph, left + r * pw, top + b * ph] + } + + fn near(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1.5) + } + + #[test] + fn a_cropped_picture_fills_the_same_place_in_every_layout() { + let crop = Crop { + left: 0.1, + right: 0.3, + top: 0.05, + bottom: 0.2, + }; + for layout in Layout::ALL { + let mut s = studio(layout); + s.camera_look.crop = Some(crop); + s.screen_look.crop = Some(crop); + s.camera_look.grade.exposure = 0.4; + let placed = s.looks(CANVAS, SCREEN, CAMERA); + // The same places as pictures already that size. + let cut = |(w, h): (u32, u32)| { + ( + (w as f32 * (1.0 - 0.1 - 0.3)).round() as u32, + (h as f32 * (1.0 - 0.05 - 0.2)).round() as u32, + ) + }; + let small = studio(layout).looks(CANVAS, SCREEN.map(cut), CAMERA.map(cut)); + for (whole, alone, size) in [ + (&placed.screen, &small.screen, SCREEN.unwrap()), + (&placed.camera, &small.camera, CAMERA.unwrap()), + ] { + let (Some(whole), Some(alone)) = (whole, alone) else { + assert_eq!(whole.is_some(), alone.is_some(), "{layout:?}"); + continue; + }; + let (a, b) = (shown(whole, size), shown(alone, cut(size))); + assert!(near(a, b), "{layout:?}: {a:?} vs {b:?}"); + // Nothing the crop took away shows. + let c = whole.crop.unwrap(); + assert!(c.left >= 0.1 - 1e-4 && c.right >= 0.3 - 1e-4, "{layout:?} {c:?}"); + } + if let Some(cam) = placed.camera { + assert_eq!(cam.grade.exposure, 0.4, "the colour comes along"); + } + } + } + #[test] fn a_studio_reads_back_what_it_wrote() { let s = Studio { @@ -491,9 +645,14 @@ mod tests { name: "C920".into(), }), system_audio: true, + camera_look: Look { + key: Some(crate::look::Key::default()), + ..Look::default() + }, ..Studio::default() }; let text = serde_json::to_string(&s).unwrap(); + assert!(!text.contains("screen_look"), "an untouched look isn't written"); assert_eq!(serde_json::from_str::(&text).unwrap(), s); assert_eq!(serde_json::from_str::("{}").unwrap(), Studio::default()); } diff --git a/crates/splice-core/src/tone.rs b/crates/splice-core/src/tone.rs new file mode 100644 index 0000000..fbfe305 --- /dev/null +++ b/crates/splice-core/src/tone.rs @@ -0,0 +1,151 @@ +//! A look's colour and key worked out directly on BGRA pixels, for live +//! pictures that can't wait for ffmpeg: the Record tab's preview. The sums +//! follow the filters export runs (`Grade::ffmpeg`, `chromakey`, `despill`) +//! closely enough to judge by; a grade's preset is left to export. + +use crate::look::{Grade, Key}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Tone { + pub grade: Grade, + pub key: Option, +} + +impl Tone { + /// Nothing to do. + pub fn is_none(&self) -> bool { + self.key.is_none() && !self.graded() + } + + /// Whether the colour changes here (a preset alone is left to export). + fn graded(&self) -> bool { + !Grade { + preset: Default::default(), + ..self.grade.clamped() + } + .is_none() + } + + /// Changes BGRA pixels in place: the key first (as export does, so a + /// grade doesn't change what counts as screen), then the colour. + pub fn apply(&self, bgra: &mut [u8]) { + if self.is_none() { + return; + } + let g = self.grade.clamped(); + let graded = self.graded(); + let k = 2f32.powf(g.exposure); + let gain = [ + (k * (1.0 - 0.14 * g.temperature)).clamp(0.0, 2.0), + (k * (1.0 - 0.1 * g.tint)).clamp(0.0, 2.0), + (k * (1.0 + 0.14 * g.temperature)).clamp(0.0, 2.0), + ]; + let contrast = if g.contrast >= 0.0 { + 1.0 + 0.8 * g.contrast + } else { + 1.0 + 0.6 * g.contrast + }; + let saturation = (1.0 + g.saturation).clamp(0.0, 3.0); + let key = self.key.map(|key| { + let (r, gr, b) = ( + ((key.color >> 16) & 0xff) as i32, + ((key.color >> 8) & 0xff) as i32, + (key.color & 0xff) as i32, + ); + let spill = if gr >= b && gr > r { + Some(1) + } else if b > gr && b > r { + Some(0) + } else { + None + }; + ( + chroma(r, gr, b), + key.similarity.clamp(0.01, 0.6), + key.blend.clamp(0.0, 0.5), + spill.filter(|_| key.despill), + ) + }); + for px in bgra.as_chunks_mut::<4>().0 { + if let Some(((ku, kv), similarity, blend, spill)) = key { + let (u, v) = chroma(px[2] as i32, px[1] as i32, px[0] as i32); + let (du, dv) = ((u - ku) as f32, (v - kv) as f32); + let diff = ((du * du + dv * dv) / (255.0 * 255.0 * 2.0)).sqrt(); + let alpha = if blend > 0.0001 { + ((diff - similarity) / blend).clamp(0.0, 1.0) + } else if diff > similarity { + 1.0 + } else { + 0.0 + }; + px[3] = (px[3] as f32 * alpha).round() as u8; + // What's left loses the screen's cast: its channel no more + // than the other two's mean. + if let Some(c) = spill { + let (a, b) = if c == 1 { (0, 2) } else { (1, 2) }; + let mean = (px[a] as u16 + px[b] as u16) / 2; + px[c] = px[c].min(mean as u8); + } + } + if !graded { + continue; + } + let mut c = [0f32; 3]; + for i in 0..3 { + c[i] = px[i] as f32 / 255.0 * gain[i]; + } + let y = 0.114 * c[0] + 0.587 * c[1] + 0.299 * c[2]; + let shown = (y - 0.5) * contrast + 0.5; + for i in 0..3 { + px[i] = ((shown + (c[i] - y) * saturation).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + } +} + +/// U and V as ffmpeg's chromakey measures them (BT.601, limited range). +fn chroma(r: i32, g: i32, b: i32) -> (i32, i32) { + ( + ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128, + ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn green_goes_clear_and_a_face_stays() { + // BGRA: pure green, then a skin tone. + let mut px = [0, 255, 0, 255, 120, 150, 210, 255]; + Tone { + key: Some(Key::default()), + ..Tone::default() + } + .apply(&mut px); + assert_eq!(px[3], 0); + assert_eq!(px[7], 255); + } + + #[test] + fn brighter_is_brighter_and_grey_is_grey() { + let mut px = [100, 100, 100, 255, 40, 80, 200, 255]; + Tone { + grade: Grade { + exposure: 0.5, + saturation: -1.0, + ..Grade::default() + }, + key: None, + } + .apply(&mut px); + assert!(px[0] > 130, "{px:?}"); + // No saturation: every channel the same. + assert!(px[4] == px[5] && px[5] == px[6], "{px:?}"); + // Untouched does nothing at all. + let mut same = [1, 2, 3, 4]; + Tone::default().apply(&mut same); + assert_eq!(same, [1, 2, 3, 4]); + } +} diff --git a/skill/splice/SKILL.md b/skill/splice/SKILL.md index 5efe293..0a75e4e 100644 --- a/skill/splice/SKILL.md +++ b/skill/splice/SKILL.md @@ -485,6 +485,10 @@ splice ctl record setup # this project's setup splice ctl record setup --screen window:Safari --camera C920 --microphone default --system splice ctl record setup --layout bubble --corner br --size 0.3 --shape circle --mirror splice ctl record setup --clean # mic clips come in with the voice cleaned up +splice ctl record adjust camera --crop-left 0.2 --exposure 0.3 --key green # shown live; takes start with it +splice ctl record adjust screen --crop-top 0.05 --saturation -0.2 +splice ctl record adjust camera --reset # the picture as it is +splice ctl record mic-mode # open macOS's Mic Mode menu (Voice Isolation) splice ctl record setup --codec hevc --quality high --fps 60 --height 1440 --default splice ctl record start [--for 20] [--beat 27 | --at 30 | --into media] splice ctl record pause | resume @@ -504,6 +508,17 @@ splice ctl record stop --wait # answers once the take is in the pro `camera.mp4`, `microphone.m4a` and `system.m4a`. - The layout is only where the take's layers are put: move or resize them afterwards like any clip (`look`). +- `record adjust screen|camera` takes `look`'s options except place and + size (the scene sets those): `--crop-left/-right/-top/-bottom`, colour + (`--exposure`, `--contrast`, `--saturation`, `--warmth`, `--tint`), `--key + green|blue|#rrggbb|none` with `--similarity`, effects and `--redact`. A + crop is laid out as if the picture were only what it keeps, in every + layout. The preview shows crop, colour and key live (a grade's preset + only in the timeline and export). Takes' clips start with it; the files + are untouched. +- `record status` has `mic_mode` (`standard`, `voice-isolation`, + `wide-spectrum`) while the mic is live; `record mic-mode` opens macOS's + menu to change it. The person chooses there. - Ask before recording, and record only what the person wants: a window is usually better than the whole screen. Splice keeps itself out of the shot. - Screen Recording, Camera and Microphone permission are each asked for the