From cfea08f3a82c9d602536bb3c48e4188e06f0dc93 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:43:06 +0100 Subject: [PATCH 01/19] fix(rendering): render background blur as a separable two-pass gaussian --- crates/rendering/src/layers/blur.rs | 57 ++++++++++++---- crates/rendering/src/lib.rs | 14 ++-- .../src/shaders/background-blur.wgsl | 65 +++++++++++-------- 3 files changed, 93 insertions(+), 43 deletions(-) diff --git a/crates/rendering/src/layers/blur.rs b/crates/rendering/src/layers/blur.rs index 59e202ac5f8..4f9ea8afd6b 100644 --- a/crates/rendering/src/layers/blur.rs +++ b/crates/rendering/src/layers/blur.rs @@ -6,13 +6,24 @@ use crate::ProjectUniforms; pub struct BlurLayer { pub blur_amount: f64, sampler: wgpu::Sampler, - uniforms_buffer: wgpu::Buffer, + uniforms_buffer_h: wgpu::Buffer, + uniforms_buffer_v: wgpu::Buffer, pipeline: BlurPipeline, cached_uniforms: Option, } impl BlurLayer { pub fn new(device: &wgpu::Device) -> Self { + let make_buffer = |direction: f32| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("BackgroundBlur Uniform Buffer"), + contents: bytemuck::cast_slice(&[BlurUniforms { + direction, + ..Default::default() + }]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }) + }; Self { blur_amount: 0.0, sampler: device.create_sampler(&wgpu::SamplerDescriptor { @@ -24,11 +35,8 @@ impl BlurLayer { mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }), - uniforms_buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("BackgroundBlur Uniform Buffer"), - contents: bytemuck::cast_slice(&[BlurUniforms::default()]), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }), + uniforms_buffer_h: make_buffer(0.0), + uniforms_buffer_v: make_buffer(1.0), pipeline: BlurPipeline::new(device), cached_uniforms: None, } @@ -44,31 +52,58 @@ impl BlurLayer { let blur_uniform = BlurUniforms { output_size: [uniforms.output_size.0 as f32, uniforms.output_size.1 as f32], blur_strength, - _padding: 0.0, + direction: 0.0, }; if self.cached_uniforms.as_ref() != Some(&blur_uniform) { queue.write_buffer( - &self.uniforms_buffer, + &self.uniforms_buffer_h, 0, bytemuck::cast_slice(&[blur_uniform]), ); + queue.write_buffer( + &self.uniforms_buffer_v, + 0, + bytemuck::cast_slice(&[BlurUniforms { + direction: 1.0, + ..blur_uniform + }]), + ); self.cached_uniforms = Some(blur_uniform); } } - pub fn render( + pub fn render_h( + &self, + pass: &mut wgpu::RenderPass<'_>, + device: &wgpu::Device, + source_texture: &wgpu::TextureView, + ) { + self.render_pass(pass, device, source_texture, &self.uniforms_buffer_h); + } + + pub fn render_v( + &self, + pass: &mut wgpu::RenderPass<'_>, + device: &wgpu::Device, + source_texture: &wgpu::TextureView, + ) { + self.render_pass(pass, device, source_texture, &self.uniforms_buffer_v); + } + + fn render_pass( &self, pass: &mut wgpu::RenderPass<'_>, device: &wgpu::Device, source_texture: &wgpu::TextureView, + uniforms_buffer: &wgpu::Buffer, ) { pass.set_pipeline(&self.pipeline.render_pipeline); pass.set_bind_group( 0, &self .pipeline - .bind_group(device, &self.uniforms_buffer, source_texture, &self.sampler), + .bind_group(device, uniforms_buffer, source_texture, &self.sampler), &[], ); pass.draw(0..4, 0..1); @@ -80,7 +115,7 @@ impl BlurLayer { pub struct BlurUniforms { output_size: [f32; 2], blur_strength: f32, - _padding: f32, + direction: f32, } pub struct BlurPipeline { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index fe56996e6df..dfca86300c5 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -5792,12 +5792,18 @@ impl RendererLayers { self.background.render(&mut pass); } + // Separable gaussian: horizontal into the spare texture, vertical back + // into the current one, so the result ends up where it started and no + // swap is needed. if self.background_blur.blur_amount > 0.0 { - let mut pass = render_pass!(session.other_texture_view(), wgpu::LoadOp::Load); + { + let mut pass = render_pass!(session.other_texture_view(), wgpu::LoadOp::Load); + self.background_blur + .render_h(&mut pass, device, session.current_texture_view()); + } + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.background_blur - .render(&mut pass, device, session.current_texture_view()); - - session.swap_textures(); + .render_v(&mut pass, device, session.other_texture_view()); } // Runs before content layers so the screen grade covers the whole diff --git a/crates/rendering/src/shaders/background-blur.wgsl b/crates/rendering/src/shaders/background-blur.wgsl index 7baca898696..dc775a68763 100644 --- a/crates/rendering/src/shaders/background-blur.wgsl +++ b/crates/rendering/src/shaders/background-blur.wgsl @@ -1,7 +1,15 @@ +// Background blur: separable gaussian, run twice (horizontal then vertical). +// +// The kernel always spans the full blur radius with evenly spaced taps; when +// the radius exceeds the tap budget the spacing widens instead of leaving +// gaps, and linear filtering interpolates between texels. Spacing stays well +// under sigma, so the widened kernel cannot alias into the comb/grid pattern +// the old single-pass sparse kernel produced. + struct Uniforms { output_size: vec2, blur_strength: f32, - _padding: f32, + direction: f32, // 0 = horizontal, 1 = vertical }; @group(0) @binding(0) var u: Uniforms; @@ -25,34 +33,35 @@ fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> VertexOutput { @fragment fn fs_main(@location(0) tex_coords: vec2) -> @location(0) vec4 { - // Early return if no blur - if (u.blur_strength <= 0.01) { - return textureSample(t_background, s_background, tex_coords); + if (u.blur_strength <= 0.001) { + return textureSampleLevel(t_background, s_background, tex_coords, 0.0); } - // Use smaller kernel for light blur - let radius = u.blur_strength * 16.0; - let sigma = radius * 0.5; - let samples = min(16, max(4, i32(radius * 0.3))); - - var color = vec4(0.0); - var total_weight = 0.0; - - for (var y = -samples; y <= samples; y++) { - for (var x = -samples; x <= samples; x++) { - let offset = vec2( - f32(x) * radius / u.output_size.x, - f32(y) * radius / u.output_size.y - ); - - let sample_pos = tex_coords + offset; - let dist = f32(x * x + y * y); - let weight = exp(-dist / (2.0 * sigma * sigma)); - - color += textureSample(t_background, s_background, sample_pos) * weight; - total_weight += weight; - } + // Sigma scales with output height so preview and export look identical; + // full strength blurs with a sigma of 2.5% of the frame height. + let sigma = u.blur_strength * u.output_size.y * 0.025; + let radius = sigma * 3.0; + let taps = min(24.0, ceil(radius)); + let spacing = radius / taps; + + var step = vec2(spacing / u.output_size.x, 0.0); + if (u.direction > 0.5) { + step = vec2(0.0, spacing / u.output_size.y); } - + + var color = textureSampleLevel(t_background, s_background, tex_coords, 0.0); + var total_weight = 1.0; + let inv_sigma2 = 1.0 / (2.0 * sigma * sigma); + let n = i32(taps); + + for (var i = 1; i <= n; i++) { + let dist = f32(i) * spacing; + let weight = exp(-dist * dist * inv_sigma2); + let offset = step * f32(i); + color += textureSampleLevel(t_background, s_background, tex_coords + offset, 0.0) * weight; + color += textureSampleLevel(t_background, s_background, tex_coords - offset, 0.0) * weight; + total_weight += 2.0 * weight; + } + return color / total_weight; -} \ No newline at end of file +} From 133000f2b97f1acce75e483e42e1fec7649c72ef Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:43:13 +0100 Subject: [PATCH 02/19] feat(project): add style, animation, and layout fields to text segments --- crates/project/src/configuration.rs | 215 ++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 16dc2dc5f7f..6f43b43ebe8 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -989,6 +989,43 @@ impl MaskSegment { } } +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TextAlign { + Left, + #[default] + Center, + Right, +} + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TextAnimation { + None, + #[default] + Fade, + SlideUp, + SlideDown, + Pop, + Typewriter, +} + +/// How a text segment shares the frame with the display recording. The +/// variants name where the TEXT sits; the display card makes room for it. +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TextLayout { + /// Text draws over the untouched display (the original behavior). + #[default] + Overlay, + /// The display card shrinks and fades away; text owns the frame. + Fullscreen, + /// Text in the left half, display card contained in the right half. + SplitLeft, + /// Text in the right half, display card contained in the left half. + SplitRight, +} + #[derive(Type, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct TextSegment { @@ -1014,8 +1051,36 @@ pub struct TextSegment { pub italic: bool, #[serde(default = "TextSegment::default_color")] pub color: String, + /// Legacy symmetric fade. Superseded by the animation fields below; kept + /// so configs written by new builds still fade in old builds. The + /// `text_anim_version` migration seeds the animation durations from it. #[serde(default = "TextSegment::default_fade_duration")] pub fade_duration: f64, + #[serde(default)] + pub align: TextAlign, + /// Px at the 1080p reference height, like `font_size`. + #[serde(default)] + pub letter_spacing: f32, + #[serde(default = "TextSegment::default_line_height")] + pub line_height: f32, + #[serde(default = "TextSegment::default_opacity")] + pub opacity: f32, + #[serde(default)] + pub shadow: f32, + #[serde(default)] + pub animation_in: TextAnimation, + #[serde(default)] + pub animation_out: TextAnimation, + #[serde(default = "TextSegment::default_fade_duration")] + pub animation_in_duration: f64, + #[serde(default = "TextSegment::default_fade_duration")] + pub animation_out_duration: f64, + #[serde(default)] + pub layout: TextLayout, + /// Seconds the display card takes to morph aside (and back) at the + /// segment edges when `layout` is not `Overlay`. + #[serde(default = "TextSegment::default_layout_transition")] + pub layout_transition: f64, } impl TextSegment { @@ -1054,6 +1119,18 @@ impl TextSegment { fn default_fade_duration() -> f64 { 0.15 } + + fn default_line_height() -> f32 { + 1.2 + } + + fn default_opacity() -> f32 { + 1.0 + } + + fn default_layout_transition() -> f64 { + 0.5 + } } #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default)] @@ -2055,9 +2132,16 @@ pub struct ProjectConfiguration { /// `Default::default()` produces the current version. #[serde(default)] pub text_size_version: u32, + /// 0 (legacy): text segments animate with the single symmetric + /// `fade_duration`. 1: the enter/exit animation fields drive timing; + /// legacy configs are migrated on load by seeding both animation + /// durations from `fade_duration`. + #[serde(default)] + pub text_anim_version: u32, } pub const TEXT_SIZE_VERSION: u32 = 1; +pub const TEXT_ANIM_VERSION: u32 = 1; fn camera_config_needs_migration(value: &Value) -> bool { value @@ -2089,6 +2173,7 @@ impl Default for ProjectConfiguration { screen_movement_spring: Default::default(), color_correction: Default::default(), text_size_version: TEXT_SIZE_VERSION, + text_anim_version: TEXT_ANIM_VERSION, } } } @@ -2157,6 +2242,21 @@ impl ProjectConfiguration { config.text_size_version = TEXT_SIZE_VERSION; } + if config.text_anim_version == 0 { + if let Some(timeline) = config.timeline.as_mut() { + for segment in &mut timeline.text_segments { + let fade = segment.fade_duration.max(0.0); + segment.animation_in_duration = fade; + segment.animation_out_duration = fade; + if fade == 0.0 { + segment.animation_in = TextAnimation::None; + segment.animation_out = TextAnimation::None; + } + } + } + config.text_anim_version = TEXT_ANIM_VERSION; + } + config .validate() .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; @@ -2732,6 +2832,17 @@ mod tests { italic: false, color: "#ffffff".to_string(), fade_duration: 0.15, + align: TextAlign::Center, + letter_spacing: 0.0, + line_height: 1.2, + opacity: 1.0, + shadow: 0.0, + animation_in: TextAnimation::Fade, + animation_out: TextAnimation::Fade, + animation_in_duration: 0.15, + animation_out_duration: 0.15, + layout: TextLayout::Overlay, + layout_transition: 0.5, }], caption_segments: Vec::new(), keyboard_segments: Vec::new(), @@ -2799,6 +2910,110 @@ mod tests { assert_eq!(segment.font_size, 96.0); } + fn write_config_with_text_fade(project_path: &std::path::Path, fade_duration: f64) { + let mut config = ProjectConfiguration { + timeline: Some(TimelineConfiguration { + segments: Vec::new(), + transitions: Vec::new(), + zoom_segments: Vec::new(), + scene_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: vec![TextSegment { + start: 0.0, + end: 1.0, + track: 0, + enabled: true, + content: "Text".to_string(), + center: XY::new(0.5, 0.5), + size: XY::new(0.35, 0.2), + font_family: "sans-serif".to_string(), + font_size: 48.0, + font_weight: 700.0, + italic: false, + color: "#ffffff".to_string(), + fade_duration, + align: TextAlign::Center, + letter_spacing: 0.0, + line_height: 1.2, + opacity: 1.0, + shadow: 0.0, + animation_in: TextAnimation::Fade, + animation_out: TextAnimation::Fade, + animation_in_duration: 0.15, + animation_out_duration: 0.15, + layout: TextLayout::Overlay, + layout_transition: 0.5, + }], + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + audio_segments: Vec::new(), + camera3d_segments: Vec::new(), + }), + ..Default::default() + }; + config.text_anim_version = 0; + + let mut value = serde_json::to_value(&config).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("textAnimVersion"); + // A legacy file predates the animation fields entirely. + if let Some(segments) = value + .pointer_mut("/timeline/textSegments") + .and_then(Value::as_array_mut) + { + for segment in segments { + let object = segment.as_object_mut().unwrap(); + for key in [ + "align", + "letterSpacing", + "lineHeight", + "opacity", + "shadow", + "animationIn", + "animationOut", + "animationInDuration", + "animationOutDuration", + ] { + object.remove(key); + } + } + } + std::fs::write( + project_path.join("project-config.json"), + serde_json::to_string(&value).unwrap(), + ) + .unwrap(); + } + + #[test] + fn legacy_text_fade_seeds_animation_durations() { + let dir = tempfile::tempdir().unwrap(); + write_config_with_text_fade(dir.path(), 0.5); + + let config = ProjectConfiguration::load(dir.path()).unwrap(); + + let segment = &config.timeline.as_ref().unwrap().text_segments[0]; + assert_eq!(segment.animation_in, TextAnimation::Fade); + assert_eq!(segment.animation_out, TextAnimation::Fade); + assert_eq!(segment.animation_in_duration, 0.5); + assert_eq!(segment.animation_out_duration, 0.5); + assert_eq!(config.text_anim_version, TEXT_ANIM_VERSION); + } + + #[test] + fn legacy_text_zero_fade_disables_animation() { + let dir = tempfile::tempdir().unwrap(); + write_config_with_text_fade(dir.path(), 0.0); + + let config = ProjectConfiguration::load(dir.path()).unwrap(); + + let segment = &config.timeline.as_ref().unwrap().text_segments[0]; + assert_eq!(segment.animation_in, TextAnimation::None); + assert_eq!(segment.animation_out, TextAnimation::None); + assert_eq!(segment.animation_in_duration, 0.0); + assert_eq!(segment.animation_out_duration, 0.0); + } + #[test] fn legacy_config_without_motion_rework_fields_resolves_defaults() { let dir = tempfile::tempdir().unwrap(); From 91cc4eacc10f7c1dfdafce3fc72f78f9aa69f6d4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:43:20 +0100 Subject: [PATCH 03/19] feat(project): pause the recording clock under fullscreen text segments --- crates/editor/src/audio.rs | 15 +- crates/export/src/lib.rs | 12 +- crates/project/src/configuration.rs | 267 +++++++++++++++++++++++++--- crates/rendering/src/zoom_spring.rs | 59 +++++- 4 files changed, 328 insertions(+), 25 deletions(-) diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index a655886d66f..a7d1a920791 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -251,7 +251,9 @@ impl AudioRenderer { project: &ProjectConfiguration, timeline: &TimelineConfiguration, ) -> Option<(usize, Vec)> { - if !timeline.transitions.is_empty() { + // Transitions and fullscreen-text holds both need the mapping-driven + // path; the accumulation fast path below knows nothing about either. + if !timeline.transitions.is_empty() || !timeline.hold_windows().is_empty() { return self.render_timeline_transition_frame_raw(samples, project, timeline); } @@ -328,7 +330,8 @@ impl AudioRenderer { }; let output_end = match mapping { TimelineFrameMapping::Single { output_end, .. } - | TimelineFrameMapping::Transition { output_end, .. } => output_end, + | TimelineFrameMapping::Transition { output_end, .. } + | TimelineFrameMapping::Hold { output_end, .. } => output_end, }; let output_end_samples = self.playhead_to_samples(output_end); if output_end_samples > self.elapsed_samples { @@ -355,6 +358,14 @@ impl AudioRenderer { ); self.cursor = source_cursor(source, source_samples + chunk_samples); } + TimelineFrameMapping::Hold { source, .. } => { + // Recording clock is paused: the chunk stays silent (the + // buffer is zero-filled) and the cursor parks on the + // frozen instant so playback resumes seamlessly. Music + // keeps playing — it mixes in output time on top. + self.cursor = + source_cursor(source, self.playhead_to_samples(source.source_time)); + } TimelineFrameMapping::Transition { outgoing, incoming, diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 3ced3f0ca15..b4857ff2a3b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -196,7 +196,17 @@ pub fn make_cursor_only_project(mut project_config: ProjectConfiguration) -> Pro if let Some(timeline) = project_config.timeline.as_mut() { timeline.mask_segments.clear(); - timeline.text_segments.clear(); + // Fullscreen text segments pause the recording clock (holds), which + // shapes the frame count and cursor motion; dropping them would + // desync this overlay pass from the main render. Keep them as + // invisible placeholders (empty content draws nothing) and only + // remove overlay-layout texts. + timeline + .text_segments + .retain(|text| text.layout == cap_project::TextLayout::Fullscreen); + for text in &mut timeline.text_segments { + text.content.clear(); + } timeline.caption_segments.clear(); timeline.keyboard_segments.clear(); } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 6f43b43ebe8..6b647c7b024 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -1564,6 +1564,14 @@ pub enum TimelineFrameMapping<'a> { duration: f64, output_end: f64, }, + /// The recording clock is paused under a fullscreen text segment: the + /// frozen `source` frame stands until `output_end` (the hold's end in + /// output time). Video shows the frozen frame (hidden behind the takeover + /// anyway); audio renders silence. + Hold { + source: TimelineSource<'a>, + output_end: f64, + }, } #[derive(Type, Serialize, Deserialize, Clone, Debug)] @@ -1628,7 +1636,93 @@ impl TimelineConfiguration { }) } + /// Output-time windows where a fullscreen text segment pauses the + /// recording clock, sorted and merged. Empty for every project without + /// fullscreen text — the mapping below then short-circuits to the exact + /// pre-hold arithmetic. + pub fn hold_windows(&self) -> Vec<(f64, f64)> { + let mut windows: Vec<(f64, f64)> = self + .text_segments + .iter() + .filter(|s| s.enabled && s.layout == TextLayout::Fullscreen && s.end > s.start) + .map(|s| (s.start, s.end)) + .collect(); + if windows.is_empty() { + return windows; + } + windows.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(windows.len()); + for window in windows { + match merged.last_mut() { + Some(last) if window.0 <= last.1 => last.1 = last.1.max(window.1), + _ => merged.push(window), + } + } + merged + } + + /// Total output seconds inserted by fullscreen text holds. + pub fn held_duration(&self) -> f64 { + self.hold_windows().iter().map(|(s, e)| e - s).sum() + } + pub fn get_frame_mapping(&self, frame_time: f64) -> Option> { + let holds = self.hold_windows(); + if holds.is_empty() { + return self.get_frame_mapping_unheld(frame_time); + } + + if let Some((hold_start, hold_end)) = active_hold_window(&holds, frame_time) { + let effective = hold_start - held_time_before(&holds, hold_start); + let source = match self.get_frame_mapping_unheld(effective)? { + TimelineFrameMapping::Single { source, .. } + | TimelineFrameMapping::Hold { source, .. } => source, + TimelineFrameMapping::Transition { incoming, .. } => incoming, + }; + return Some(TimelineFrameMapping::Hold { + source, + output_end: hold_end, + }); + } + + let effective = frame_time - held_time_before(&holds, frame_time); + let next_hold_start = holds + .iter() + .map(|(start, _)| *start) + .find(|start| *start > frame_time); + // The base mapping's output_end is in the un-held (gapless) domain; + // put it back into output time and stop at the next hold so consumers + // (audio chunking) never render contiguous recording samples across a + // pause. + let clamp_end = |output_end: f64| { + let output_end = effective_to_output(&holds, output_end); + next_hold_start.map_or(output_end, |hold| output_end.min(hold)) + }; + Some(match self.get_frame_mapping_unheld(effective)? { + TimelineFrameMapping::Single { source, output_end } => TimelineFrameMapping::Single { + source, + output_end: clamp_end(output_end), + }, + TimelineFrameMapping::Transition { + outgoing, + incoming, + kind, + progress, + duration, + output_end, + } => TimelineFrameMapping::Transition { + outgoing, + incoming, + kind, + progress, + duration, + output_end: clamp_end(output_end), + }, + hold @ TimelineFrameMapping::Hold { .. } => hold, + }) + } + + fn get_frame_mapping_unheld(&self, frame_time: f64) -> Option> { if self.transitions.is_empty() { return self.get_segment_time_without_transitions(frame_time).map( |(source_time, segment, segment_index, output_end)| TimelineFrameMapping::Single { @@ -1701,19 +1795,15 @@ impl TimelineConfiguration { } pub fn get_segment_time(&self, frame_time: f64) -> Option<(f64, &TimelineSegment)> { - if !self.transitions.is_empty() { - return match self.get_frame_mapping(frame_time)? { - TimelineFrameMapping::Single { source, .. } => { - Some((source.source_time, source.segment)) - } - TimelineFrameMapping::Transition { incoming, .. } => { - Some((incoming.source_time, incoming.segment)) - } - }; + match self.get_frame_mapping(frame_time)? { + TimelineFrameMapping::Single { source, .. } + | TimelineFrameMapping::Hold { source, .. } => { + Some((source.source_time, source.segment)) + } + TimelineFrameMapping::Transition { incoming, .. } => { + Some((incoming.source_time, incoming.segment)) + } } - - self.get_segment_time_without_transitions(frame_time) - .map(|(source_time, segment, _, _)| (source_time, segment)) } fn get_segment_time_without_transitions( @@ -1743,19 +1833,49 @@ impl TimelineConfiguration { } pub fn duration(&self) -> f64 { - let segment_duration = self.segments.iter().map(TimelineSegment::duration).sum(); - if self.transitions.is_empty() { - return segment_duration; - } + let segment_duration: f64 = self.segments.iter().map(TimelineSegment::duration).sum(); + let segment_duration = if self.transitions.is_empty() { + segment_duration + } else { + segment_duration + - (1..self.segments.len()) + .filter_map(|segment_index| self.effective_transition(segment_index)) + .map(|transition| transition.duration) + .sum::() + }; - segment_duration - - (1..self.segments.len()) - .filter_map(|segment_index| self.effective_transition(segment_index)) - .map(|transition| transition.duration) - .sum::() + segment_duration + self.held_duration() } } +fn active_hold_window(windows: &[(f64, f64)], time: f64) -> Option<(f64, f64)> { + windows + .iter() + .find(|(start, end)| time >= *start && time < *end) + .copied() +} + +fn held_time_before(windows: &[(f64, f64)], time: f64) -> f64 { + windows + .iter() + .map(|(start, end)| (time.min(*end) - start).max(0.0)) + .sum() +} + +/// Inverse of the held-output -> gapless transform: places a gapless +/// timestamp back into output time, landing after every hold it passed. +fn effective_to_output(windows: &[(f64, f64)], effective: f64) -> f64 { + let mut output = effective; + for (start, end) in windows { + if output >= *start { + output += end - start; + } else { + break; + } + } + output +} + pub const WALLPAPERS_PATH: &str = "assets/backgrounds/macOS"; #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] @@ -2443,6 +2563,111 @@ mod tests { } } + fn fullscreen_text(start: f64, end: f64) -> TextSegment { + TextSegment { + start, + end, + track: 0, + enabled: true, + content: "Title".to_string(), + center: XY::new(0.5, 0.5), + size: XY::new(0.35, 0.2), + font_family: "sans-serif".to_string(), + font_size: 48.0, + font_weight: 700.0, + italic: false, + color: "#ffffff".to_string(), + fade_duration: 0.15, + align: TextAlign::Center, + letter_spacing: 0.0, + line_height: 1.2, + opacity: 1.0, + shadow: 0.0, + animation_in: TextAnimation::Fade, + animation_out: TextAnimation::Fade, + animation_in_duration: 0.15, + animation_out_duration: 0.15, + layout: TextLayout::Fullscreen, + layout_transition: 0.5, + } + } + + #[test] + fn fullscreen_text_inserts_output_time() { + let mut timeline = timeline_with_transitions(Vec::new()); + timeline.text_segments = vec![fullscreen_text(2.0, 5.0)]; + + assert_eq!(timeline.duration(), 13.0); + + // Before the hold: unchanged mapping, but the chunk ends at the hold. + assert!(matches!( + timeline.get_frame_mapping(1.0), + Some(TimelineFrameMapping::Single { source, output_end }) + if source.source_time == 1.0 && output_end == 2.0 + )); + + // Inside the hold: frozen at the recording instant where it started. + assert!(matches!( + timeline.get_frame_mapping(3.5), + Some(TimelineFrameMapping::Hold { source, output_end }) + if source.source_time == 2.0 && output_end == 5.0 + )); + let (frozen, _) = timeline.get_segment_time(3.5).unwrap(); + assert_eq!(frozen, 2.0); + + // After the hold: resumes exactly where it paused. + let (resumed, _) = timeline.get_segment_time(5.0).unwrap(); + assert_eq!(resumed, 2.0); + let (later, segment) = timeline.get_segment_time(7.5).unwrap(); + assert_eq!(later, 10.5); + assert_eq!(segment.recording_clip, 1); + } + + #[test] + fn overlay_and_disabled_texts_do_not_hold() { + let mut timeline = timeline_with_transitions(Vec::new()); + let mut overlay = fullscreen_text(2.0, 5.0); + overlay.layout = TextLayout::Overlay; + let mut disabled = fullscreen_text(6.0, 8.0); + disabled.enabled = false; + timeline.text_segments = vec![overlay, disabled]; + + assert_eq!(timeline.duration(), 10.0); + assert!(timeline.hold_windows().is_empty()); + let (time, _) = timeline.get_segment_time(4.5).unwrap(); + assert_eq!(time, 10.5); + } + + #[test] + fn overlapping_fullscreen_texts_merge_into_one_hold() { + let mut timeline = timeline_with_transitions(Vec::new()); + timeline.text_segments = vec![fullscreen_text(2.0, 5.0), fullscreen_text(4.0, 6.0)]; + + assert_eq!(timeline.hold_windows(), vec![(2.0, 6.0)]); + assert_eq!(timeline.duration(), 14.0); + let (frozen, _) = timeline.get_segment_time(5.5).unwrap(); + assert_eq!(frozen, 2.0); + let (after, _) = timeline.get_segment_time(6.5).unwrap(); + assert_eq!(after, 2.5); + } + + #[test] + fn holds_compose_with_transitions() { + let mut timeline = timeline_with_transitions(vec![ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 1.0, + }]); + timeline.text_segments = vec![fullscreen_text(1.0, 2.0)]; + + assert_eq!(timeline.duration(), 10.0); + // 6.0 output = 5.0 effective = 2.0s into the second clip (whose + // output start is 3.0 after the 1s cross-fade overlap). + let (time, segment) = timeline.get_segment_time(6.0).unwrap(); + assert_eq!(segment.recording_clip, 1); + assert_eq!(time, 12.0); + } + #[test] fn timeline_without_transitions_keeps_legacy_mapping() { let timeline = timeline_with_transitions(Vec::new()); diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index 540e71c7de2..7e42d010799 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -284,7 +284,64 @@ fn build_time_map(timeline: Option<&TimelineConfiguration>) -> Vec= piece_start_out + remaining { + break; + } + if hold_start > piece_start_out { + let len = hold_start - piece_start_out; + out.push(TimeMapSegment { + timeline_start: piece_start_out, + timeline_end: hold_start, + recording_start: piece_start_rec, + timescale: segment.timescale, + recording_clip: segment.recording_clip, + }); + piece_start_rec += len * segment.timescale; + remaining -= len; + piece_start_out = hold_start; + } + let hold_len = hold_end - hold_start; + out.push(TimeMapSegment { + timeline_start: piece_start_out, + timeline_end: piece_start_out + hold_len, + recording_start: piece_start_rec, + timescale: 0.0, + recording_clip: segment.recording_clip, + }); + piece_start_out += hold_len; + shift += hold_len; + hold_idx += 1; + } + if remaining > 0.0 { + out.push(TimeMapSegment { + timeline_start: piece_start_out, + timeline_end: piece_start_out + remaining, + recording_start: piece_start_rec, + timescale: segment.timescale, + recording_clip: segment.recording_clip, + }); + } + } + out } /// Maps a timeline timestamp to recording seconds. Identity when no timeline From b06a3065e25aa41bbe56f3a0e38b5d74eb88f4bc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:43:43 +0100 Subject: [PATCH 04/19] feat(rendering): text enter/exit animations, alignment, spacing, and shadow --- crates/rendering/src/layers/text.rs | 126 ++++++++++--- crates/rendering/src/text.rs | 271 ++++++++++++++++++++++++++-- 2 files changed, 358 insertions(+), 39 deletions(-) diff --git a/crates/rendering/src/layers/text.rs b/crates/rendering/src/layers/text.rs index abb07188ddd..e4a94b62f42 100644 --- a/crates/rendering/src/layers/text.rs +++ b/crates/rendering/src/layers/text.rs @@ -1,3 +1,4 @@ +use cap_project::TextAlign; use glyphon::cosmic_text::Align; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, Style, @@ -17,6 +18,24 @@ pub struct TextLayer { buffers: Vec, } +struct AreaSpec { + bounds: TextBounds, + left: f32, + top: f32, + scale: f32, + color: Color, + shadow: Option<(f32, f32, Color)>, +} + +fn shift_bounds(bounds: TextBounds, dx: f32, dy: f32) -> TextBounds { + TextBounds { + left: bounds.left + dx.floor() as i32, + top: bounds.top + dy.floor() as i32, + right: bounds.right + dx.ceil() as i32, + bottom: bounds.bottom + dy.ceil() as i32, + } +} + impl TextLayer { pub fn new(device: &Device, queue: &Queue) -> Self { let font_system = super::new_font_system(); @@ -50,7 +69,7 @@ impl TextLayer { ) { self.buffers.clear(); self.buffers.reserve(texts.len()); - let mut text_area_data = Vec::with_capacity(texts.len()); + let mut specs = Vec::with_capacity(texts.len()); for text in texts { let alpha = text.color[3].clamp(0.0, 1.0) * text.opacity.clamp(0.0, 1.0); @@ -67,18 +86,23 @@ impl TextLayer { // Shape with a little more width than the editor-measured box: // the webview and cosmic-text can disagree by a few pixels per // line, and without slack a line that fit in the editor wraps in - // the render. The room is split evenly so centered lines stay - // centered. Boxes already spanning the frame keep their exact - // width — there the editor genuinely wrapped too. + // the render. The room is placed so the aligned edge stays put — + // split for centered text, after for left, before for right. + // Boxes already spanning the frame keep their exact width — there + // the editor genuinely wrapped too. let output_width = (output_size.0 as f32).max(1.0); let wrap_width = if width < output_width * 0.98 { (width * 1.05 + 4.0).min(output_width.max(width)) } else { width }; - let wrap_dx = (wrap_width - width) / 2.0; + let origin_dx = match text.align { + TextAlign::Left => 0.0, + TextAlign::Center => (wrap_width - width) / 2.0, + TextAlign::Right => wrap_width - width, + }; - let metrics = Metrics::new(text.font_size, text.font_size * 1.2); + let metrics = Metrics::new(text.font_size, text.font_size * text.line_height); let mut buffer = Buffer::new(&mut self.font_system, metrics); // The box only constrains wrapping; height is unbounded so every // line is laid out even when the configured box is a little @@ -99,15 +123,22 @@ impl TextLayer { }, }; let weight = Weight(text.font_weight.round().clamp(100.0, 900.0) as u16); - let attrs = Attrs::new() + // Glyph color comes from each area's default_color (not Attrs) so + // the shadow pass can re-tint the same shaped buffer. + let mut attrs = Attrs::new() .family(family) - .color(color) .weight(weight) .style(if text.italic { Style::Italic } else { Style::Normal }); + if text.letter_spacing != 0.0 { + // cosmic-text adds letter_spacing to the em-relative glyph + // advance and multiplies by font size at layout (shape.rs), so + // the attr is in em — convert from our px value. + attrs = attrs.letter_spacing(text.letter_spacing / text.font_size.max(1.0)); + } buffer.set_text( &mut self.font_system, @@ -116,42 +147,85 @@ impl TextLayer { Shaping::Advanced, ); + let align = match text.align { + TextAlign::Left => Align::Left, + TextAlign::Center => Align::Center, + TextAlign::Right => Align::Right, + }; for line in buffer.lines.iter_mut() { - line.set_align(Some(Align::Center)); + line.set_align(Some(align)); } buffer.shape_until_scroll(&mut self.font_system, false); + let laid_out_height = buffer.layout_runs().count() as f32 * metrics.line_height; + + // Animation transform: uniform scale about the box center plus a + // translation, applied to the buffer origin and clip bounds (the + // glyph layout itself is scaled by TextArea::scale from that + // origin). + let cx = (text.bounds[0] + text.bounds[2]) / 2.0; + let cy = (text.bounds[1] + text.bounds[3]) / 2.0; + let scale = text.scale.max(0.01); + let tx = |x: f32| cx + (x - cx) * scale + text.offset[0]; + let ty = |y: f32| cy + (y - cy) * scale + text.offset[1]; + + let origin_left = tx(text.bounds[0] - origin_dx); + let origin_top = ty(text.bounds[1]); + // Clip horizontally at the (slack-expanded) wrap box, but extend // the bottom to the laid-out text height so descenders and extra // lines never get cut off; glyphon intersects these bounds with // the viewport. - let laid_out_height = buffer.layout_runs().count() as f32 * metrics.line_height; let bounds = TextBounds { - left: (text.bounds[0] - wrap_dx).floor() as i32, - top: text.bounds[1].floor() as i32, - right: (text.bounds[0] + width + wrap_dx).ceil() as i32, - bottom: (text.bounds[1] + height.max(laid_out_height)).ceil() as i32, + left: origin_left.floor() as i32, + top: origin_top.floor() as i32, + right: tx(text.bounds[0] - origin_dx + wrap_width).ceil() as i32, + bottom: ty(text.bounds[1] + height.max(laid_out_height)).ceil() as i32, }; + let shadow = (text.shadow > 0.0).then(|| { + let dx = text.font_size * scale * 0.02; + let dy = text.font_size * scale * 0.055; + let shadow_alpha = alpha * text.shadow.clamp(0.0, 1.0) * 0.85; + (dx, dy, Color::rgba(0, 0, 0, (shadow_alpha * 255.0) as u8)) + }); + self.buffers.push(buffer); - // The buffer origin shifts left by the slack so centered lines - // stay centered on the box. - text_area_data.push((bounds, text.bounds[0] - wrap_dx, text.bounds[1], color)); + specs.push(AreaSpec { + bounds, + left: origin_left, + top: origin_top, + scale, + color, + shadow, + }); } let text_areas = self .buffers .iter() - .zip(text_area_data) - .map(|(buffer, (bounds, left, top, color))| TextArea { - buffer, - left, - top, - scale: 1.0, - bounds, - default_color: color, - custom_glyphs: &[], + .zip(&specs) + .flat_map(|(buffer, spec)| { + let shadow_area = spec.shadow.map(|(dx, dy, shadow_color)| TextArea { + buffer, + left: spec.left + dx, + top: spec.top + dy, + scale: spec.scale, + bounds: shift_bounds(spec.bounds, dx, dy), + default_color: shadow_color, + custom_glyphs: &[], + }); + let main_area = TextArea { + buffer, + left: spec.left, + top: spec.top, + scale: spec.scale, + bounds: spec.bounds, + default_color: spec.color, + custom_glyphs: &[], + }; + shadow_area.into_iter().chain(std::iter::once(main_area)) }) .collect::>(); diff --git a/crates/rendering/src/text.rs b/crates/rendering/src/text.rs index 5fb66bd6157..29bd89046bb 100644 --- a/crates/rendering/src/text.rs +++ b/crates/rendering/src/text.rs @@ -1,4 +1,4 @@ -use cap_project::{TextSegment, XY}; +use cap_project::{TextAlign, TextAnimation, TextSegment, XY}; /// Text font sizes are authored against a 1080p-tall reference frame and /// scaled to the output height, so a project renders identically at every @@ -19,6 +19,17 @@ pub struct PreparedText { pub font_weight: f32, pub italic: bool, pub opacity: f32, + pub align: TextAlign, + /// Output px, already scaled from the 1080p reference. + pub letter_spacing: f32, + /// Multiplier of `font_size`. + pub line_height: f32, + /// 0..1 drop-shadow strength; 0 disables the shadow pass. + pub shadow: f32, + /// Animation translation in output px, applied to the whole block. + pub offset: [f32; 2], + /// Animation scale about the box center. + pub scale: f32, } fn parse_color(hex: &str) -> [f32; 4] { @@ -36,6 +47,100 @@ fn parse_color(hex: &str) -> [f32; 4] { [1.0, 1.0, 1.0, 1.0] } +fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +fn ease_out_back(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let c1 = 1.70158; + let c3 = c1 + 1.0; + let p = t - 1.0; + 1.0 + c3 * p * p * p + c1 * p * p +} + +const POP_MIN_SCALE: f32 = 0.8; + +#[derive(Debug, Clone, Copy)] +struct AnimSample { + alpha: f32, + offset: [f32; 2], + scale: f32, + /// Fraction of characters visible (typewriter); 1 for other styles. + reveal: f32, +} + +const ANIM_REST: AnimSample = AnimSample { + alpha: 1.0, + offset: [0.0, 0.0], + scale: 1.0, + reveal: 1.0, +}; + +/// `progress` runs 0 → 1 toward fully visible for both edges: time since +/// start over the enter duration, time until end over the exit duration. +/// `direction` is +1 entering and -1 exiting so slides continue through the +/// text's resting position (enter from below, exit above) instead of +/// retracing themselves. +fn sample_animation( + style: TextAnimation, + progress: f32, + direction: f32, + slide_px: f32, +) -> AnimSample { + if progress >= 1.0 { + return ANIM_REST; + } + let eased = ease_out_cubic(progress); + + match style { + TextAnimation::None => ANIM_REST, + TextAnimation::Fade => AnimSample { + alpha: eased, + ..ANIM_REST + }, + TextAnimation::SlideUp => AnimSample { + alpha: eased, + offset: [0.0, direction * (1.0 - eased) * slide_px], + ..ANIM_REST + }, + TextAnimation::SlideDown => AnimSample { + alpha: eased, + offset: [0.0, -direction * (1.0 - eased) * slide_px], + ..ANIM_REST + }, + TextAnimation::Pop => AnimSample { + alpha: eased, + scale: POP_MIN_SCALE + (1.0 - POP_MIN_SCALE) * ease_out_back(progress), + ..ANIM_REST + }, + TextAnimation::Typewriter => AnimSample { + reveal: progress, + ..ANIM_REST + }, + } +} + +fn edge_progress(elapsed: f64, duration: f64) -> f32 { + if duration <= 0.0 { + 1.0 + } else { + (elapsed / duration).clamp(0.0, 1.0) as f32 + } +} + +/// Truncates to the first `ceil(reveal * chars)` characters on a char +/// boundary, so the typewriter reveal never splits a code point. +fn reveal_content(content: &str, reveal: f32) -> String { + if reveal >= 1.0 { + return content.to_string(); + } + let total = content.chars().count(); + let visible = ((total as f32) * reveal.max(0.0)).ceil() as usize; + content.chars().take(visible).collect() +} + pub fn prepare_texts( output_size: XY, frame_time: f64, @@ -77,30 +182,170 @@ pub fn prepare_texts( let right = left + width; let bottom = top + height; - let fade_duration = segment.fade_duration.max(0.0); - let opacity = if fade_duration > 0.0 { - let time_since_start = (frame_time - segment.start).max(0.0); - let time_until_end = (segment.end - frame_time).max(0.0); + let font_size = segment.font_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) * height_scale; + let slide_px = font_size * 0.5; + + let enter = sample_animation( + segment.animation_in, + edge_progress(frame_time - segment.start, segment.animation_in_duration), + 1.0, + slide_px, + ); + let exit = sample_animation( + segment.animation_out, + edge_progress(segment.end - frame_time, segment.animation_out_duration), + -1.0, + slide_px, + ); - let fade_in = (time_since_start / fade_duration).min(1.0); - let fade_out = (time_until_end / fade_duration).min(1.0); + let opacity = segment.opacity.clamp(0.0, 1.0) * enter.alpha * exit.alpha; + if opacity <= 0.0 { + continue; + } - (fade_in * fade_out) as f32 - } else { - 1.0 - }; + let reveal = enter.reveal.min(exit.reveal); + let content = reveal_content(&segment.content, reveal); + if content.is_empty() { + continue; + } prepared.push(PreparedText { - content: segment.content.clone(), + content, bounds: [left, top, right, bottom], color: parse_color(&segment.color), font_family: segment.font_family.clone(), - font_size: segment.font_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) * height_scale, + font_size, font_weight: segment.font_weight, italic: segment.italic, opacity, + align: segment.align, + letter_spacing: segment.letter_spacing.clamp(-24.0, 240.0) * height_scale, + line_height: segment.line_height.clamp(0.5, 3.0), + shadow: segment.shadow.clamp(0.0, 1.0), + offset: [ + enter.offset[0] + exit.offset[0], + enter.offset[1] + exit.offset[1], + ], + scale: (enter.scale * exit.scale).max(0.01), }); } prepared } + +#[cfg(test)] +mod tests { + use super::*; + use cap_project::{TextAnimation, TextLayout}; + + fn segment(animation_in: TextAnimation, animation_out: TextAnimation) -> TextSegment { + TextSegment { + start: 0.0, + end: 10.0, + track: 0, + enabled: true, + content: "Hello".to_string(), + center: XY::new(0.5, 0.5), + size: XY::new(0.35, 0.2), + font_family: "sans-serif".to_string(), + font_size: 48.0, + font_weight: 700.0, + italic: false, + color: "#ffffff".to_string(), + fade_duration: 0.15, + align: TextAlign::Center, + letter_spacing: 0.0, + line_height: 1.2, + opacity: 1.0, + shadow: 0.0, + animation_in, + animation_out, + animation_in_duration: 1.0, + animation_out_duration: 1.0, + layout: TextLayout::Overlay, + layout_transition: 0.5, + } + } + + fn prepare_one(seg: TextSegment, time: f64) -> Option { + prepare_texts(XY::new(1920, 1080), time, &[seg], &[]) + .into_iter() + .next() + } + + #[test] + fn resting_text_has_identity_animation_state() { + let text = prepare_one(segment(TextAnimation::Fade, TextAnimation::Fade), 5.0).unwrap(); + assert_eq!(text.opacity, 1.0); + assert_eq!(text.offset, [0.0, 0.0]); + assert_eq!(text.scale, 1.0); + assert_eq!(text.content, "Hello"); + } + + #[test] + fn fade_ramps_in_and_out() { + let early = prepare_one(segment(TextAnimation::Fade, TextAnimation::Fade), 0.25).unwrap(); + assert!(early.opacity > 0.0 && early.opacity < 1.0); + + let late = prepare_one(segment(TextAnimation::Fade, TextAnimation::Fade), 9.75).unwrap(); + assert!(late.opacity > 0.0 && late.opacity < 1.0); + } + + #[test] + fn slide_up_enters_from_below_and_exits_above() { + let entering = prepare_one( + segment(TextAnimation::SlideUp, TextAnimation::SlideUp), + 0.25, + ) + .unwrap(); + assert!(entering.offset[1] > 0.0); + + let exiting = prepare_one( + segment(TextAnimation::SlideUp, TextAnimation::SlideUp), + 9.75, + ) + .unwrap(); + assert!(exiting.offset[1] < 0.0); + } + + #[test] + fn pop_scales_up_from_min() { + let entering = prepare_one(segment(TextAnimation::Pop, TextAnimation::None), 0.05).unwrap(); + assert!(entering.scale < 1.0); + assert!(entering.scale >= POP_MIN_SCALE); + } + + #[test] + fn typewriter_reveals_characters_over_time() { + let seg = segment(TextAnimation::Typewriter, TextAnimation::None); + let mid = prepare_one(seg.clone(), 0.5).unwrap(); + assert!(mid.content.len() < "Hello".len()); + assert!(mid.content.starts_with(&seg.content[..1])); + assert_eq!(mid.opacity, 1.0); + + let done = prepare_one(seg, 2.0).unwrap(); + assert_eq!(done.content, "Hello"); + } + + #[test] + fn typewriter_start_renders_nothing() { + assert!( + prepare_one(segment(TextAnimation::Typewriter, TextAnimation::None), 0.0).is_none() + ); + } + + #[test] + fn none_animation_shows_instantly() { + let text = prepare_one(segment(TextAnimation::None, TextAnimation::None), 0.0).unwrap(); + assert_eq!(text.opacity, 1.0); + assert_eq!(text.scale, 1.0); + } + + #[test] + fn segment_opacity_multiplies_animation_alpha() { + let mut seg = segment(TextAnimation::None, TextAnimation::None); + seg.opacity = 0.5; + let text = prepare_one(seg, 5.0).unwrap(); + assert_eq!(text.opacity, 0.5); + } +} From 6606be1acb3fb8af1042c6bdc1002c7fa66a9484 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:08 +0100 Subject: [PATCH 05/19] feat(rendering): morph the display card aside for text takeover layouts --- crates/rendering/src/layers/captions.rs | 2 +- crates/rendering/src/layers/cursor.rs | 28 +++ crates/rendering/src/layers/keyboard.rs | 2 +- crates/rendering/src/lib.rs | 130 +++++++++-- crates/rendering/src/takeover.rs | 285 ++++++++++++++++++++++++ 5 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 crates/rendering/src/takeover.rs diff --git a/crates/rendering/src/layers/captions.rs b/crates/rendering/src/layers/captions.rs index 549b78a9430..6a259c96d8f 100644 --- a/crates/rendering/src/layers/captions.rs +++ b/crates/rendering/src/layers/captions.rs @@ -534,7 +534,7 @@ impl CaptionsLayer { active.segment.start, effective_end, segment_fade, - ); + ) * uniforms.takeover_overlay_fade(); if fade_opacity <= 0.0 { self.current_text = None; return; diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index db479d057c7..4b265d8100e 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -594,6 +594,34 @@ impl CursorLayer { ], }; + // A text takeover relocates the display card by a uniform scale + + // translate (the takeover target preserves the card's aspect), so the + // cursor follows with the same affine map — and fades with the card + // when a Fullscreen takeover hides it. + let (position_size, cursor_opacity) = match &uniforms.takeover { + Some(takeover) if takeover.t > 0.001 => { + let from_w = (takeover.from[2] - takeover.from[0]).max(f32::EPSILON); + let scale = (takeover.to[2] - takeover.to[0]) / from_w; + let mapped = [ + takeover.to[0] + (position_size[0] - takeover.from[0]) * scale, + takeover.to[1] + (position_size[1] - takeover.from[1]) * scale, + position_size[2] * scale, + position_size[3] * scale, + ]; + let t = takeover.t; + ( + [ + crate::lerp_f32(position_size[0], mapped[0], t), + crate::lerp_f32(position_size[1], mapped[1], t), + crate::lerp_f32(position_size[2], mapped[2], t), + crate::lerp_f32(position_size[3], mapped[3], t), + ], + cursor_opacity * takeover.cursor_fade, + ) + } + _ => (position_size, cursor_opacity), + }; + let cursor_grade = if uniforms.project.color_correction.grade_cursor { uniforms.screen_color_grade } else { diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs index 97cf4206381..74f26a279d9 100644 --- a/crates/rendering/src/layers/keyboard.rs +++ b/crates/rendering/src/layers/keyboard.rs @@ -343,7 +343,7 @@ impl KeyboardLayer { active.segment.start, active.segment.end, segment_fade, - ); + ) * uniforms.takeover_overlay_fade(); if fade_opacity <= 0.0 { return; diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index dfca86300c5..9ad3f5ea46b 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -48,6 +48,7 @@ pub mod notch_shape; mod project_recordings; mod scene; pub mod spring_mass_damper; +mod takeover; mod text; mod transition; pub mod yuv_converter; @@ -74,6 +75,8 @@ use camera3d::{Camera3DFrame, interpolate_camera3d}; pub use cursor_interpolation::PrecomputedCursorTimeline; use mask::interpolate_masks; use scene::*; +use takeover::InterpolatedTakeover; +pub use takeover::TakeoverDisplayMorph; use text::{PreparedText, prepare_texts}; use zoom::*; pub use zoom_spring::{CursorCropMap, ZoomTransformTimeline}; @@ -2263,6 +2266,10 @@ pub struct ProjectUniforms { pub zoom: InterpolatedZoom, pub scene: InterpolatedScene, pub split: Option, + /// Display-card morph driven by a non-Overlay text segment; `None` when + /// no takeover is active. The cursor layer follows it the same way it + /// follows `split`. + pub takeover: Option, pub resolution_base: XY, pub display_parent_motion_px: XY, pub motion_blur_amount: f32, @@ -2745,6 +2752,15 @@ pub(crate) struct DisplayLayout { } impl ProjectUniforms { + /// 0..1 multiplier for recording-anchored overlays (captions, keyboard) + /// while a text takeover hides the recording — they would otherwise hang + /// frozen over the title card while the recording clock is paused. + pub fn takeover_overlay_fade(&self) -> f32 { + self.takeover + .as_ref() + .map_or(1.0, |takeover| 1.0 - takeover.t) + } + pub fn frame_layout(&self) -> FrameLayout { FrameLayout { display: self.display_outer_bounds, @@ -3519,8 +3535,23 @@ impl ProjectUniforms { None }; + // Text-takeover morph: a non-Overlay text segment pushes the display + // aside (Fullscreen) or into a padded half (Split*). None on every + // frame without such a segment, which leaves all the math below + // exactly as it was. + let takeover = project.timeline.as_ref().and_then(|timeline| { + InterpolatedTakeover::sample(frame_time as f64, &timeline.text_segments) + }); + let mut camera3d_zoom: Option = None; - let (display, display_motion_parent, frame_chrome, display_outer_bounds, notch) = { + let ( + display, + display_motion_parent, + frame_chrome, + display_outer_bounds, + notch, + takeover_morph, + ) = { let output_size = XY::new(output_size.0 as f64, output_size.1 as f64); let size = [options.screen_size.x as f32, options.screen_size.y as f32]; @@ -3615,6 +3646,28 @@ impl ProjectUniforms { let final_crop_bounds = split_layout.as_ref().map_or(base_crop_bounds, |s| { s.screen.crop_for(final_target_bounds, split_t) }); + + // Text takeover composes after the split morph at the same seam: + // lerp the (possibly split) rect toward the takeover target. The + // target preserves the current rect's aspect, so the crop derived + // above stays valid and content never distorts. + let takeover_t = takeover.map_or(0.0, |tk| tk.t); + let takeover_display_fade = takeover.map_or(1.0, |tk| tk.display_fade()); + let takeover_accessory_fade = takeover.map_or(1.0, |tk| tk.accessory_fade()); + let takeover_card_t = takeover.map_or(0.0, |tk| tk.card_style_t()); + let takeover_padding = output_size.x.min(output_size.y) as f32 * FLOATING_PADDING_FRAC; + let pre_takeover_bounds = final_target_bounds; + let takeover_target = takeover.map(|tk| { + tk.display_target( + pre_takeover_bounds, + (output_size.x as f32, output_size.y as f32), + takeover_padding, + ) + }); + let final_target_bounds = takeover_target.map_or(final_target_bounds, |target| { + lerp_bounds(pre_takeover_bounds, target, takeover_t) + }); + let final_target_size = [ final_target_bounds[2] - final_target_bounds[0], final_target_bounds[3] - final_target_bounds[1], @@ -3624,6 +3677,9 @@ impl ProjectUniforms { let display_rounding_px = (project.background.rounding / 100.0 * 0.5 * final_min_axis) as f32 * split_fade + floating_rounding_px * floating_t; + // A split takeover styles the display as a floating card. + let display_rounding_px = + lerp_f32(display_rounding_px, floating_rounding_px, takeover_card_t); let frame_active = frame_config.is_some(); // With a frame active the card decoration (shadow/border) moves to // the chrome pass; the video keeps only the floating-card shadow @@ -3640,16 +3696,23 @@ impl ProjectUniforms { // fades out, so the multipliers relax back to uniform rounding. let display_corner_radii = match frame_config.as_ref().map(|f| f.style) { Some(FrameStyle::MacOS | FrameStyle::Windows | FrameStyle::Browser) => { - [split_t, split_t, 1.0, 1.0] + // The chrome bar also fades out under a takeover, so the + // top corners regain their rounding the same way they do + // in a split. + let top = split_t.max(takeover_t); + [top, top, 1.0, 1.0] } _ => [1.0; 4], }; + // The shader draws border and shadow outside the card shape, + // unscaled by the opacity uniform — fade them explicitly or they + // outlive a Fullscreen takeover's fade. let border_color = if let Some(b) = project.background.border.as_ref() { [ b.color[0] as f32 / 255.0, b.color[1] as f32 / 255.0, b.color[2] as f32 / 255.0, - (b.opacity / 100.0).clamp(0.0, 1.0), + (b.opacity / 100.0).clamp(0.0, 1.0) * takeover_display_fade, ] } else { [0.0, 0.0, 0.0, 0.0] @@ -3678,6 +3741,9 @@ impl ProjectUniforms { let chrome_bounds = split_layout.as_ref().map_or(base_outer_bounds, |s| { lerp_bounds(base_outer_bounds, s.screen.target, split_t) }); + let chrome_bounds = takeover_target.map_or(chrome_bounds, |target| { + lerp_bounds(chrome_bounds, target, takeover_t) + }); let chrome_size = [ chrome_bounds[2] - chrome_bounds[0], chrome_bounds[3] - chrome_bounds[1], @@ -3705,7 +3771,10 @@ impl ProjectUniforms { 0.0, ], shadow: if decorated { - project.background.shadow * split_fade * camera3d_shadow_fade + project.background.shadow + * split_fade + * camera3d_shadow_fade + * takeover_accessory_fade } else { 0.0 }, @@ -3720,13 +3789,14 @@ impl ProjectUniforms { .as_ref() .map_or(18.0, |s| s.opacity) * split_fade - * camera3d_shadow_fade, + * camera3d_shadow_fade + * takeover_accessory_fade, shadow_blur: project .background .advanced_shadow .as_ref() .map_or(50.0, |s| s.blur), - opacity: scene.screen_opacity as f32 * split_fade, + opacity: scene.screen_opacity as f32 * split_fade * takeover_accessory_fade, border_enabled: if decorated && border_on { 1.0 } else { 0.0 }, border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), preserve_source_alpha: 1.0, @@ -3790,9 +3860,12 @@ impl ProjectUniforms { shadow_size: 0.0, shadow_opacity: 0.0, shadow_blur: 0.0, - // Fades out as a split-screen scene morphs in, where - // the geometry above stops describing the pane. - opacity: scene.screen_opacity as f32 * split_fade, + // Fades out as a split-screen scene or a text + // takeover morphs in, where the geometry above + // stops describing the pane. + opacity: scene.screen_opacity as f32 + * split_fade + * takeover_accessory_fade, border_enabled: 0.0, border_width: 0.0, _padding1: [0.0; 3], @@ -3829,7 +3902,8 @@ impl ProjectUniforms { ], shadow: project.background.shadow * display_decoration_fade - * camera3d_shadow_fade, + * camera3d_shadow_fade + * takeover_display_fade, shadow_size: project .background .advanced_shadow @@ -3841,13 +3915,14 @@ impl ProjectUniforms { .as_ref() .map_or(18.0, |s| s.opacity) * display_decoration_fade - * camera3d_shadow_fade, + * camera3d_shadow_fade + * takeover_display_fade, shadow_blur: project .background .advanced_shadow .as_ref() .map_or(50.0, |s| s.blur), - opacity: scene.screen_opacity as f32, + opacity: scene.screen_opacity as f32 * takeover_display_fade, border_enabled: if border_on && !frame_active { 1.0 } else { 0.0 }, border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), preserve_source_alpha: if options.preserve_screen_alpha { @@ -3866,6 +3941,14 @@ impl ProjectUniforms { frame_chrome, display_outer_bounds, notch, + takeover + .zip(takeover_target) + .map(|(tk, target)| TakeoverDisplayMorph { + t: tk.t, + from: pre_takeover_bounds, + to: target, + cursor_fade: tk.display_fade(), + }), ) }; @@ -3999,6 +4082,10 @@ impl ProjectUniforms { // Same chrome rule as the display layer: classic split strips // rounding/shadow, the floating card keeps them. let chrome_fade = (1.0 - split_t + floating_t).clamp(0.0, 1.0); + // The shader draws the drop shadow outside the card without the + // opacity uniform, so a takeover must fade the shadow uniforms + // explicitly or it outlives the hidden bubble. + let takeover_fade = takeover.map_or(1.0, |tk| tk.accessory_fade()); let final_target_bounds = snap_bounds_to_output_pixels( split_layout.as_ref().map_or(target_bounds, |s| { lerp_bounds(target_bounds, s.camera.target, split_t) @@ -4038,7 +4125,10 @@ impl ProjectUniforms { camera_descriptor.zoom_amount, 0.0, ], - shadow: project.camera.shadow * chrome_fade * camera3d_shadow_fade, + shadow: project.camera.shadow + * chrome_fade + * camera3d_shadow_fade + * takeover_fade, shadow_size: project .camera .advanced_shadow @@ -4050,13 +4140,16 @@ impl ProjectUniforms { .as_ref() .map_or(18.0, |s| s.opacity) * chrome_fade - * camera3d_shadow_fade, + * camera3d_shadow_fade + * takeover_fade, shadow_blur: project .camera .advanced_shadow .as_ref() .map_or(50.0, |s| s.blur), - opacity: scene.regular_camera_transition_opacity() as f32, + // The bubble yields to a text takeover so it can never + // collide with the text's half of the frame. + opacity: scene.regular_camera_transition_opacity() as f32 * takeover_fade, border_enabled: 0.0, border_width: 0.0, preserve_source_alpha: 0.0, @@ -4156,7 +4249,8 @@ impl ProjectUniforms { shadow_size: 0.0, shadow_opacity: 0.0, shadow_blur: 0.0, - opacity: scene.camera_only_transition_opacity() as f32, + opacity: scene.camera_only_transition_opacity() as f32 + * takeover.map_or(1.0, |tk| tk.accessory_fade()), border_enabled: 0.0, border_width: 0.0, preserve_source_alpha: 0.0, @@ -4209,6 +4303,7 @@ impl ProjectUniforms { zoom, scene, split: split_layout, + takeover: takeover_morph, interpolated_cursor, frame_rate: fps, frame_number, @@ -5822,6 +5917,9 @@ impl RendererLayers { let should_render_screen = render_display && uniforms.scene.should_render_screen() + // A fully-faded card (e.g. a held Fullscreen text takeover) draws + // nothing visible; skip the pass entirely. + && uniforms.display.opacity > 0.001 && self.display.has_valid_frame(); let should_render_cursor = if render_display { uniforms.scene.should_render_screen() diff --git a/crates/rendering/src/takeover.rs b/crates/rendering/src/takeover.rs new file mode 100644 index 00000000000..62dd38d7ed3 --- /dev/null +++ b/crates/rendering/src/takeover.rs @@ -0,0 +1,285 @@ +use cap_project::{TextLayout, TextSegment}; + +/// At full takeover a Fullscreen text leaves the display card at this scale +/// (about its center) as it fades, so the hand-off reads as a push-back +/// rather than a bare crossfade. +pub const TAKEOVER_FULLSCREEN_SHRINK: f32 = 0.92; +const MIN_TRANSITION: f64 = 0.05; + +/// Per-frame state of the text-takeover morph: how far (`t`, eased 0..1) the +/// display has yielded the frame to a non-Overlay text segment, and which +/// layout it is yielding to. `None` when every active text segment is a plain +/// overlay — the pipeline then computes exactly what it did before this +/// feature existed. +#[derive(Clone, Copy, Debug)] +pub struct InterpolatedTakeover { + pub t: f32, + pub layout: TextLayout, +} + +/// Cursor-side view of the display morph: the display card's pre-takeover +/// rect, its rect at full takeover, and the morph amount. The takeover +/// target preserves the card's aspect, so the cursor remap is a uniform +/// scale + translate. +#[derive(Clone, Copy, Debug)] +pub struct TakeoverDisplayMorph { + pub t: f32, + pub from: [f32; 4], + pub to: [f32; 4], + /// Multiplier for the cursor sprite's opacity (fades with the display in + /// Fullscreen, stays 1 for splits where the card remains visible). + pub cursor_fade: f32, +} + +impl InterpolatedTakeover { + pub fn sample(frame_time: f64, segments: &[TextSegment]) -> Option { + let mut best: Option<(f32, f64, TextLayout)> = None; + for segment in segments { + if !segment.enabled || segment.layout == TextLayout::Overlay { + continue; + } + if frame_time < segment.start || frame_time > segment.end { + continue; + } + let half = ((segment.end - segment.start) / 2.0).max(MIN_TRANSITION); + let duration = segment.layout_transition.clamp(MIN_TRANSITION, half); + let progress = ((frame_time - segment.start) / duration) + .min((segment.end - frame_time) / duration) + .clamp(0.0, 1.0) as f32; + if progress <= 0.0 { + continue; + } + let replace = match best { + None => true, + Some((t, start, _)) => progress > t || (progress == t && segment.start > start), + }; + if replace { + best = Some((progress, segment.start, segment.layout)); + } + } + best.map(|(progress, _, layout)| { + // Same cubic-bezier(0.42, 0, 0.58, 1) the scene transitions use, + // so display morphs feel identical across features. + let ease_in_out = bezier_easing::bezier_easing(0.42, 0.0, 0.58, 1.0).unwrap(); + Self { + t: ease_in_out(progress), + layout, + } + }) + } + + /// The display card's rect at FULL takeover (`t == 1`); callers lerp from + /// the pre-takeover rect toward it by `t`. Splits contain-fit the card's + /// current aspect into the padded opposite half, so the crop — and with + /// it the visible content — never distorts mid-morph. + pub fn display_target( + &self, + base: [f32; 4], + output_size: (f32, f32), + padding: f32, + ) -> [f32; 4] { + match self.layout { + TextLayout::Overlay => base, + TextLayout::Fullscreen => shrink_about_center(base, TAKEOVER_FULLSCREEN_SHRINK), + TextLayout::SplitLeft | TextLayout::SplitRight => { + let (out_w, out_h) = output_size; + let mid = out_w * 0.5; + let half_box = if self.layout == TextLayout::SplitLeft { + [mid + padding, padding, out_w - padding, out_h - padding] + } else { + [padding, padding, mid - padding, out_h - padding] + }; + contain_aspect(base, half_box) + } + } + } + + /// Multiplier for the display card's own opacity, and for the decoration + /// (shadow/border) the shader draws OUTSIDE the card shape — the fragment + /// returns those unscaled by the opacity uniform, so they must fade here + /// or a ghost shadow outlives a Fullscreen takeover. + pub fn display_fade(&self) -> f32 { + match self.layout { + TextLayout::Fullscreen => 1.0 - self.t, + _ => 1.0, + } + } + + /// Multiplier for layers whose geometry stops describing the morphed + /// card (notch, frame chrome) or that would collide with the text + /// (camera bubble, camera-only plane). + pub fn accessory_fade(&self) -> f32 { + 1.0 - self.t + } + + /// 0..1 amount of floating-card styling (rounding) the display picks up; + /// only splits become cards — a Fullscreen card keeps its look while it + /// fades. + pub fn card_style_t(&self) -> f32 { + match self.layout { + TextLayout::SplitLeft | TextLayout::SplitRight => self.t, + _ => 0.0, + } + } +} + +fn shrink_about_center(bounds: [f32; 4], scale: f32) -> [f32; 4] { + let cx = (bounds[0] + bounds[2]) * 0.5; + let cy = (bounds[1] + bounds[3]) * 0.5; + let hw = (bounds[2] - bounds[0]) * 0.5 * scale; + let hh = (bounds[3] - bounds[1]) * 0.5 * scale; + [cx - hw, cy - hh, cx + hw, cy + hh] +} + +fn contain_aspect(base: [f32; 4], container: [f32; 4]) -> [f32; 4] { + let base_w = (base[2] - base[0]).max(f32::EPSILON); + let base_h = (base[3] - base[1]).max(f32::EPSILON); + let aspect = base_w / base_h; + let box_w = (container[2] - container[0]).max(f32::EPSILON); + let box_h = (container[3] - container[1]).max(f32::EPSILON); + let (w, h) = if box_w / box_h > aspect { + (box_h * aspect, box_h) + } else { + (box_w, box_w / aspect) + }; + let cx = (container[0] + container[2]) * 0.5; + let cy = (container[1] + container[3]) * 0.5; + [cx - w * 0.5, cy - h * 0.5, cx + w * 0.5, cy + h * 0.5] +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_project::{TextAlign, TextAnimation, XY}; + + fn segment(layout: TextLayout, start: f64, end: f64) -> TextSegment { + TextSegment { + start, + end, + track: 0, + enabled: true, + content: "Text".to_string(), + center: XY::new(0.5, 0.5), + size: XY::new(0.35, 0.2), + font_family: "sans-serif".to_string(), + font_size: 48.0, + font_weight: 700.0, + italic: false, + color: "#ffffff".to_string(), + fade_duration: 0.15, + align: TextAlign::Center, + letter_spacing: 0.0, + line_height: 1.2, + opacity: 1.0, + shadow: 0.0, + animation_in: TextAnimation::Fade, + animation_out: TextAnimation::Fade, + animation_in_duration: 0.15, + animation_out_duration: 0.15, + layout, + layout_transition: 0.5, + } + } + + #[test] + fn overlay_segments_never_produce_a_takeover() { + let segments = [segment(TextLayout::Overlay, 0.0, 10.0)]; + assert!(InterpolatedTakeover::sample(5.0, &segments).is_none()); + } + + #[test] + fn takeover_is_none_outside_the_segment() { + let segments = [segment(TextLayout::Fullscreen, 2.0, 4.0)]; + assert!(InterpolatedTakeover::sample(1.0, &segments).is_none()); + assert!(InterpolatedTakeover::sample(5.0, &segments).is_none()); + assert!(InterpolatedTakeover::sample(2.0, &segments).is_none()); + } + + #[test] + fn takeover_ramps_in_and_out() { + let segments = [segment(TextLayout::Fullscreen, 0.0, 10.0)]; + let entering = InterpolatedTakeover::sample(0.25, &segments).unwrap(); + assert!(entering.t > 0.0 && entering.t < 1.0); + let held = InterpolatedTakeover::sample(5.0, &segments).unwrap(); + assert_eq!(held.t, 1.0); + let exiting = InterpolatedTakeover::sample(9.75, &segments).unwrap(); + assert!(exiting.t > 0.0 && exiting.t < 1.0); + } + + #[test] + fn disabled_segments_are_ignored() { + let mut seg = segment(TextLayout::Fullscreen, 0.0, 10.0); + seg.enabled = false; + assert!(InterpolatedTakeover::sample(5.0, &[seg]).is_none()); + } + + #[test] + fn dominant_segment_wins() { + let fading = segment(TextLayout::Fullscreen, 0.0, 5.1); + let entering = segment(TextLayout::SplitLeft, 5.0, 10.0); + let sampled = InterpolatedTakeover::sample(5.05, &[fading, entering]).unwrap(); + // Both are near-zero activity at the crossover; the later start wins + // ties, and by 5.4 the entering split clearly dominates. + let later = InterpolatedTakeover::sample( + 5.4, + &[ + segment(TextLayout::Fullscreen, 0.0, 5.1), + segment(TextLayout::SplitLeft, 5.0, 10.0), + ], + ) + .unwrap(); + assert_eq!(later.layout, TextLayout::SplitLeft); + assert!(sampled.t <= later.t); + } + + #[test] + fn split_target_preserves_aspect_in_opposite_half() { + let takeover = InterpolatedTakeover { + t: 1.0, + layout: TextLayout::SplitLeft, + }; + let base = [100.0, 100.0, 1820.0, 980.0]; + let target = takeover.display_target(base, (1920.0, 1080.0), 54.0); + let base_aspect = (base[2] - base[0]) / (base[3] - base[1]); + let target_aspect = (target[2] - target[0]) / (target[3] - target[1]); + assert!((base_aspect - target_aspect).abs() < 1e-3); + // Text-left means the card lives entirely in the right half. + assert!(target[0] >= 960.0); + assert!(target[2] <= 1920.0); + } + + #[test] + fn fullscreen_target_shrinks_about_center() { + let takeover = InterpolatedTakeover { + t: 1.0, + layout: TextLayout::Fullscreen, + }; + let base = [0.0, 0.0, 1920.0, 1080.0]; + let target = takeover.display_target(base, (1920.0, 1080.0), 54.0); + assert!((target[0] + target[2] - 1920.0).abs() < 1e-3); + assert!((target[1] + target[3] - 1080.0).abs() < 1e-3); + assert!((target[2] - target[0]) < 1920.0); + assert_eq!(takeover.display_fade(), 0.0); + } + + #[test] + fn split_keeps_display_visible() { + let takeover = InterpolatedTakeover { + t: 1.0, + layout: TextLayout::SplitRight, + }; + assert_eq!(takeover.display_fade(), 1.0); + assert_eq!(takeover.accessory_fade(), 0.0); + assert_eq!(takeover.card_style_t(), 1.0); + } + + #[test] + fn transition_clamps_to_half_segment_length() { + let mut seg = segment(TextLayout::Fullscreen, 0.0, 0.4); + seg.layout_transition = 5.0; + // With the transition clamped to 0.2s, the midpoint reaches full + // takeover instead of being stuck near zero. + let mid = InterpolatedTakeover::sample(0.2, &[seg]).unwrap(); + assert_eq!(mid.t, 1.0); + } +} From d6dafc5eaba7060fe1d2e9edd8e2b49008fe0b54 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:08 +0100 Subject: [PATCH 06/19] feat(desktop): configurable default auto-zoom amount --- .../desktop/src-tauri/src/general_settings.rs | 3 + apps/desktop/src-tauri/src/lib.rs | 12 +++- apps/desktop/src-tauri/src/recording.rs | 56 +++++++++++++++---- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 8ea2ce2fd9c..ac95fae0244 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -194,6 +194,8 @@ pub struct GeneralSettingsStore { pub enable_native_camera_preview: bool, #[serde(default = "default_true")] pub auto_zoom_on_clicks: bool, + #[serde(default)] + pub default_zoom_amount: Option, /// `None` until [`init`] seeds it from whether this machine has a notched /// display. From then on it is the user's preference and nothing re-reads /// the hardware, so moving between machines can't silently flip it. @@ -328,6 +330,7 @@ impl Default for GeneralSettingsStore { // Keep aligned with the field's serde `default_true`: auto zooms // are on by default, matching configs that never stored the key. auto_zoom_on_clicks: true, + default_zoom_amount: None, macbook_notch_overlay: None, capture_keyboard_events: cap_recording::DEFAULT_CAPTURE_KEYBOARD_EVENTS, post_deletion_behaviour: PostDeletionBehaviour::DoNothing, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c58541d522..35e55318526 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3380,14 +3380,22 @@ async fn update_project_config_in_memory( #[tauri::command] #[specta::specta] -#[instrument(skip(editor_instance))] +#[instrument(skip(app, editor_instance))] async fn generate_zoom_segments_from_clicks( + app: AppHandle, editor_instance: WindowEditorInstance, ) -> Result, String> { let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings); + let zoom_amount = GeneralSettingsStore::get(&app) + .ok() + .flatten() + .and_then(|settings| settings.default_zoom_amount) + .unwrap_or(recording::DEFAULT_AUTO_ZOOM_AMOUNT); + + let zoom_segments = + recording::generate_zoom_segments_for_project(meta, recordings, zoom_amount); Ok(zoom_segments) } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 31c97af3525..48cdd475c75 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3701,10 +3701,13 @@ async fn finalize_studio_recording( Ok(()) } +pub const DEFAULT_AUTO_ZOOM_AMOUNT: f64 = 2.0; + fn generate_zoom_segments_from_clicks_impl( mut clicks: Vec, _moves: Vec, max_duration: f64, + zoom_amount: f64, ) -> Vec { const MS_PER_SECOND: f64 = 1000.0; const START_MIN_MS: f64 = 1.0; @@ -3713,7 +3716,6 @@ fn generate_zoom_segments_from_clicks_impl( const CLICK_END_CLAMP_PADDING_MS: f64 = 800.0; const TRAILING_CLICK_IGNORE_MS: f64 = 1000.0; const MERGE_GAP_MS: f64 = 2500.0; - const AUTO_ZOOM_AMOUNT: f64 = 2.0; if max_duration <= 0.0 { return Vec::new(); @@ -3769,7 +3771,7 @@ fn generate_zoom_segments_from_clicks_impl( .map(|(start, end)| ZoomSegment { start: start.round() / MS_PER_SECOND, end: end.round() / MS_PER_SECOND, - amount: AUTO_ZOOM_AMOUNT, + amount: zoom_amount, mode: ZoomMode::Auto, glide_direction: GlideDirection::None, glide_speed: 0.5, @@ -3784,6 +3786,7 @@ fn generate_zoom_segments_from_clicks_impl( pub fn generate_zoom_segments_from_clicks( recording: &studio_recording::CompletedRecording, recordings: &ProjectRecordingsMeta, + zoom_amount: f64, ) -> Vec { // Build a temporary RecordingMeta so we can use the common implementation let recording_meta = RecordingMeta { @@ -3795,7 +3798,7 @@ pub fn generate_zoom_segments_from_clicks( upload: None, }; - generate_zoom_segments_for_project(&recording_meta, recordings) + generate_zoom_segments_for_project(&recording_meta, recordings, zoom_amount) } /// Generates zoom segments from clicks for an existing project. @@ -3803,6 +3806,7 @@ pub fn generate_zoom_segments_from_clicks( pub fn generate_zoom_segments_for_project( recording_meta: &RecordingMeta, recordings: &ProjectRecordingsMeta, + zoom_amount: f64, ) -> Vec { let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else { return Vec::new(); @@ -3835,7 +3839,12 @@ pub fn generate_zoom_segments_for_project( } } - generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration()) + generate_zoom_segments_from_clicks_impl( + all_clicks, + all_moves, + recordings.duration(), + zoom_amount, + ) } fn project_config_from_recording( @@ -3898,7 +3907,13 @@ fn project_config_from_recording( .collect::>(); let zoom_segments = if settings.auto_zoom_on_clicks { - generate_zoom_segments_from_clicks(completed_recording, recordings) + generate_zoom_segments_from_clicks( + completed_recording, + recordings, + settings + .default_zoom_amount + .unwrap_or(DEFAULT_AUTO_ZOOM_AMOUNT), + ) } else { Vec::new() }; @@ -4271,8 +4286,12 @@ mod tests { #[test] fn skips_trailing_stop_click() { - let segments = - generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0); + let segments = generate_zoom_segments_from_clicks_impl( + vec![click_event(11_900.0)], + vec![], + 12.0, + DEFAULT_AUTO_ZOOM_AMOUNT, + ); assert!( segments.is_empty(), @@ -4289,7 +4308,8 @@ mod tests { move_event(1_940.0, 0.74, 0.78), ]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0); + let segments = + generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, DEFAULT_AUTO_ZOOM_AMOUNT); assert!( !segments.is_empty(), @@ -4317,7 +4337,12 @@ mod tests { move_event(19_364.0, 0.44, 0.95), ]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 19.436_667); + let segments = generate_zoom_segments_from_clicks_impl( + clicks, + moves, + 19.436_667, + DEFAULT_AUTO_ZOOM_AMOUNT, + ); assert_eq!(segments.len(), 2); assert_eq!(segments[0].start, 1.971); @@ -4330,7 +4355,8 @@ mod tests { fn extends_segment_until_after_mouse_up() { let clicks = vec![click_event(1_000.0), click_up_event(2_500.0)]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0); + let segments = + generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0, DEFAULT_AUTO_ZOOM_AMOUNT); assert_eq!(segments.len(), 1); assert_eq!(segments[0].start, 0.7); @@ -4341,7 +4367,8 @@ mod tests { fn clamps_zoom_end_before_recording_end() { let clicks = vec![click_event(8_999.0), click_event(9_000.0)]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0); + let segments = + generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0, DEFAULT_AUTO_ZOOM_AMOUNT); assert_eq!(segments.len(), 1); assert_eq!(segments[0].start, 8.699); @@ -4358,7 +4385,12 @@ mod tests { }) .collect::>(); - let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0); + let segments = generate_zoom_segments_from_clicks_impl( + Vec::new(), + jitter_moves, + 15.0, + DEFAULT_AUTO_ZOOM_AMOUNT, + ); assert!( segments.is_empty(), From 21b203886e615ac2f3bdd0d10da919bd15cb2819 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:13 +0100 Subject: [PATCH 07/19] feat(desktop): expose installed system font families to the editor --- apps/desktop/src-tauri/src/lib.rs | 10 ++++++++++ crates/rendering/src/lib.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 35e55318526..01b185c9757 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3459,6 +3459,15 @@ async fn list_audio_devices() -> Result, ()> { Ok(MicrophoneFeed::list_names()) } +#[tauri::command] +#[specta::specta] +#[instrument] +async fn list_system_fonts() -> Vec { + tokio::task::spawn_blocking(cap_rendering::system_font_families) + .await + .unwrap_or_default() +} + #[derive(Serialize, Type, Debug, Clone)] pub struct UploadProgress { progress: f64, @@ -4935,6 +4944,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { windows::refresh_window_content_protection, general_settings::get_default_excluded_windows, list_audio_devices, + list_system_fonts, close_recordings_overlay_window, fake_window::set_fake_window_bounds, fake_window::remove_fake_window, diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 9ad3f5ea46b..31f9ed924e0 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -71,6 +71,38 @@ pub fn prewarm_fonts() { drop(layers::new_font_system()); } +/// Unique installed font family names, sorted, for the editor's font picker. +/// Reuses the process-wide font database scan (see [`prewarm_fonts`]). +/// Dot-prefixed families (macOS-internal UI fonts) are hidden the same way +/// browser font pickers hide them. +pub fn system_font_families() -> Vec { + let font_system = layers::new_font_system(); + let mut families = std::collections::BTreeSet::new(); + for face in font_system.db().faces() { + if let Some((name, _)) = face.families.first() + && !name.starts_with('.') + && !name.is_empty() + { + families.insert(name.clone()); + } + } + families.into_iter().collect() +} + +#[cfg(test)] +mod font_family_tests { + #[test] + fn system_font_families_are_deduped_and_visible() { + let families = super::system_font_families(); + assert!(!families.is_empty()); + assert!(families.iter().all(|name| !name.starts_with('.'))); + let mut sorted = families.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(families, sorted); + } +} + use camera3d::{Camera3DFrame, interpolate_camera3d}; pub use cursor_interpolation::PrecomputedCursorTimeline; use mask::interpolate_masks; From 70f535d084afce06138c4bfa7607c68600152348 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:19 +0100 Subject: [PATCH 08/19] chore(desktop): extract specta builder and export bindings from a test --- apps/desktop/src-tauri/src/lib.rs | 97 ++++++++++++++++++------------- apps/desktop/src/utils/tauri.ts | 54 ++++++++++++++++- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 01b185c9757..ce0094df8cc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4875,45 +4875,8 @@ fn configure_camera_blur_recovery( } } -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { - // Arm the unexpected-termination sentinel before anything else can crash, and - // report any previous session that died without a clean shutdown. - let previous_termination = crash_sentinel::init(&logs_dir, env!("CARGO_PKG_VERSION")); - configure_windows_graphics_recovery(previous_termination); - - // Keep the sentinel's blur marker in sync with live BlurProcessor instances - // (camera preview and editor render alike), so a native blur crash is - // attributable on the next launch. - cap_camera_effects::set_blur_session_observer(|active| { - if active { - crash_sentinel::enter_blur_session(); - } else { - crash_sentinel::exit_blur_session(); - } - }); - - ffmpeg::init() - .map_err(|e| { - error!("Failed to initialize ffmpeg: {e}"); - }) - .ok(); - - // Detect the camera-preview quality profile once from total RAM. On low-RAM - // machines (<= 8GB) this opts the preview into a cheaper profile (smaller - // textures, 30fps, no background blur); higher-spec machines keep the exact - // current behaviour. Only the preview is affected — recording is untouched. - { - let mut system = sysinfo::System::new(); - system.refresh_memory(); - camera::init_preview_profile(system.total_memory()); - } - - telemetry::init(); - - let tauri_context = tauri::generate_context!(); - - let specta_builder = tauri_specta::Builder::new() +fn specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::new() .commands(tauri_specta::collect_commands![ set_mic_input, set_camera_input, @@ -5130,7 +5093,48 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .typ::() .typ::() .typ::() - .typ::(); + .typ::() +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { + // Arm the unexpected-termination sentinel before anything else can crash, and + // report any previous session that died without a clean shutdown. + let previous_termination = crash_sentinel::init(&logs_dir, env!("CARGO_PKG_VERSION")); + configure_windows_graphics_recovery(previous_termination); + + // Keep the sentinel's blur marker in sync with live BlurProcessor instances + // (camera preview and editor render alike), so a native blur crash is + // attributable on the next launch. + cap_camera_effects::set_blur_session_observer(|active| { + if active { + crash_sentinel::enter_blur_session(); + } else { + crash_sentinel::exit_blur_session(); + } + }); + + ffmpeg::init() + .map_err(|e| { + error!("Failed to initialize ffmpeg: {e}"); + }) + .ok(); + + // Detect the camera-preview quality profile once from total RAM. On low-RAM + // machines (<= 8GB) this opts the preview into a cheaper profile (smaller + // textures, 30fps, no background blur); higher-spec machines keep the exact + // current behaviour. Only the preview is affected — recording is untouched. + { + let mut system = sysinfo::System::new(); + system.refresh_memory(); + camera::init_preview_profile(system.total_memory()); + } + + telemetry::init(); + + let tauri_context = tauri::generate_context!(); + + let specta_builder = specta_builder(); #[cfg(debug_assertions)] { @@ -6920,3 +6924,16 @@ mod screenshot_share_cache_tests { assert!(link.is_none()); } } + +#[cfg(test)] +mod typescript_bindings_tests { + #[test] + fn export_typescript_bindings() { + let bindings_path = std::path::Path::new("../src/utils/tauri.ts"); + if bindings_path.parent().is_some_and(|parent| parent.exists()) { + super::specta_builder() + .export(specta_typescript::Typescript::default(), bindings_path) + .expect("failed to export TypeScript bindings"); + } + } +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 62f92eace7c..862333938f3 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -92,6 +92,9 @@ async getDefaultExcludedWindows() : Promise { async listAudioDevices() : Promise { return await TAURI_INVOKE("list_audio_devices"); }, +async listSystemFonts() : Promise { + return await TAURI_INVOKE("list_system_fonts"); +}, async closeRecordingsOverlayWindow() : Promise { await TAURI_INVOKE("close_recordings_overlay_window"); }, @@ -955,7 +958,7 @@ export type FrameStyle = "macbook" export type FrameTheme = "dark" | "light" export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; defaultZoomAmount?: number | null; /** * `None` until [`init`] seeds it from whether this machine has a notched * display. From then on it is the user's preference and nothing re-reads @@ -1067,7 +1070,14 @@ colorCorrection?: ColorCorrectionConfiguration; * `font_size`. The field-level default keeps old files at 0 while * `Default::default()` produces the current version. */ -textSizeVersion?: number } +textSizeVersion?: number; +/** + * 0 (legacy): text segments animate with the single symmetric + * `fade_duration`. 1: the enter/exit animation fields drive timing; + * legacy configs are migrated on load by seeding both animation + * durations from `fade_duration`. + */ +textAnimVersion?: number } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } @@ -1132,7 +1142,45 @@ export type StudioRecordingQuality = "compatibility" | "balanced" | "ultra" export type StudioRecordingStatus = { status: "InProgress" } | { status: "NeedsRemux" } | { status: "Failed"; error: string } | { status: "Complete" } export type SystemDiagnostics = { macosVersion: MacOSVersionInfo | null; availableEncoders: string[]; screenCaptureSupported: boolean; metalSupported: boolean; gpuName: string | null } export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } -export type TextSegment = { start: number; end: number; track?: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; fadeDuration?: number } +export type TextAlign = "left" | "center" | "right" +export type TextAnimation = "none" | "fade" | "slideUp" | "slideDown" | "pop" | "typewriter" +/** + * How a text segment shares the frame with the display recording. The + * variants name where the TEXT sits; the display card makes room for it. + */ +export type TextLayout = +/** + * Text draws over the untouched display (the original behavior). + */ +"overlay" | +/** + * The display card shrinks and fades away; text owns the frame. + */ +"fullscreen" | +/** + * Text in the left half, display card contained in the right half. + */ +"splitLeft" | +/** + * Text in the right half, display card contained in the left half. + */ +"splitRight" +export type TextSegment = { start: number; end: number; track?: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; +/** + * Legacy symmetric fade. Superseded by the animation fields below; kept + * so configs written by new builds still fade in old builds. The + * `text_anim_version` migration seeds the animation durations from it. + */ +fadeDuration?: number; align?: TextAlign; +/** + * Px at the 1080p reference height, like `font_size`. + */ +letterSpacing?: number; lineHeight?: number; opacity?: number; shadow?: number; animationIn?: TextAnimation; animationOut?: TextAnimation; animationInDuration?: number; animationOutDuration?: number; layout?: TextLayout; +/** + * Seconds the display card takes to morph aside (and back) at the + * segment edges when `layout` is not `Overlay`. + */ +layoutTransition?: number } export type TimelineConfiguration = { segments: TimelineSegment[]; transitions: ClipTransition[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[]; camera3dSegments?: Camera3DSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number; name?: string | null; speedAudioMode?: ClipSpeedAudioMode | null } export type TranscriptionEngine = "Whisper" | "Parakeet" From 1814f3c2b80c9e254e0cc672de25328d4a1b8515 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:26 +0100 Subject: [PATCH 09/19] feat(desktop): default zoom amount control in general settings --- .../(window-chrome)/settings/general.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 46e7b205259..a0f6636579f 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -25,7 +25,7 @@ import toast from "solid-toast"; import themePreviewAuto from "~/assets/theme-previews/auto.jpg"; import themePreviewDark from "~/assets/theme-previews/dark.jpg"; import themePreviewLight from "~/assets/theme-previews/light.jpg"; -import { Input } from "~/routes/editor/ui"; +import { Input, Slider } from "~/routes/editor/ui"; import { authStore, generalSettingsStore, @@ -662,6 +662,26 @@ function Inner(props: { value={!!settings.autoZoomOnClicks} onChange={(value) => handleChange("autoZoomOnClicks", value)} /> + +
+ setSettings("defaultZoomAmount", v[0])} + onChangeEnd={(v) => handleChange("defaultZoomAmount", v[0])} + minValue={1} + maxValue={4.5} + step={0.1} + formatTooltip="x" + /> + + {`${(settings.defaultZoomAmount ?? 1.5).toFixed(1)}x`} + +
+
Date: Fri, 14 Aug 2026 12:44:27 +0100 Subject: [PATCH 10/19] feat(desktop): font picker backed by installed system fonts --- apps/desktop/src/routes/editor/FontPicker.tsx | 96 +++++++++++++++++++ apps/desktop/src/utils/fonts.ts | 39 ++++++++ 2 files changed, 135 insertions(+) create mode 100644 apps/desktop/src/routes/editor/FontPicker.tsx create mode 100644 apps/desktop/src/utils/fonts.ts diff --git a/apps/desktop/src/routes/editor/FontPicker.tsx b/apps/desktop/src/routes/editor/FontPicker.tsx new file mode 100644 index 00000000000..5d3ecfbc5d7 --- /dev/null +++ b/apps/desktop/src/routes/editor/FontPicker.tsx @@ -0,0 +1,96 @@ +import { Combobox as KCombobox } from "@kobalte/core/combobox"; +import { cx } from "cva"; +import { createMemo, createResource } from "solid-js"; +import { + cssFontFamily, + fontFamilyLabel, + GENERIC_FONT_OPTIONS, + listSystemFonts, +} from "~/utils/fonts"; +import { + MenuItem, + MenuItemList, + PopperContent, + topSlideAnimateClasses, +} from "./ui"; + +type FontOption = { value: string; label: string }; + +export function FontPicker(props: { + value: string; + onChange: (family: string) => void; +}) { + const [installedFonts] = createResource(listSystemFonts); + + const options = createMemo(() => [ + ...GENERIC_FONT_OPTIONS, + ...(installedFonts() ?? []).map((name) => ({ value: name, label: name })), + ]); + + const selected = createMemo( + () => + options().find((option) => option.value === props.value) ?? { + value: props.value, + label: fontFamilyLabel(props.value), + }, + ); + + return ( + + options={options()} + optionValue="value" + optionTextValue="label" + optionLabel="label" + value={selected()} + onChange={(option) => { + if (option) props.onChange(option.value); + }} + defaultFilter="contains" + placeholder="Search fonts…" + itemComponent={(itemProps) => ( + + as={KCombobox.Item} + item={itemProps.item} + > + + {itemProps.item.rawValue.label} + + + + + + )} + > + + + + + + + + + + + as={KCombobox.Content} + class={cx( + topSlideAnimateClasses, + "z-50 w-(--kb-popper-anchor-width)", + )} + > + + class="overflow-y-auto max-h-64" + as={KCombobox.Listbox} + /> + + + + ); +} diff --git a/apps/desktop/src/utils/fonts.ts b/apps/desktop/src/utils/fonts.ts new file mode 100644 index 00000000000..87205cf37a6 --- /dev/null +++ b/apps/desktop/src/utils/fonts.ts @@ -0,0 +1,39 @@ +import { invoke } from "@tauri-apps/api/core"; + +let fontsPromise: Promise | null = null; + +// Enumerated Rust-side from the same fontdb the renderer shapes with, so +// every family returned here is guaranteed resolvable at render time. +export function listSystemFonts(): Promise { + fontsPromise ??= invoke("list_system_fonts").catch(() => { + fontsPromise = null; + return []; + }); + return fontsPromise; +} + +export const GENERIC_FONT_OPTIONS = [ + { value: "sans-serif", label: "System Sans" }, + { value: "serif", label: "System Serif" }, + { value: "monospace", label: "System Mono" }, +]; + +const GENERIC_VALUES = new Set(GENERIC_FONT_OPTIONS.map((o) => o.value)); + +export function isGenericFontFamily(value: string) { + return GENERIC_VALUES.has(value.trim().toLowerCase()); +} + +export function fontFamilyLabel(value: string) { + const generic = GENERIC_FONT_OPTIONS.find( + (option) => option.value === value.trim().toLowerCase(), + ); + return generic ? generic.label : value; +} + +// CSS font-family value for previewing a picked family in the webview. +export function cssFontFamily(value: string) { + const trimmed = value.trim(); + if (isGenericFontFamily(trimmed)) return trimmed.toLowerCase(); + return `"${trimmed.replaceAll('"', "")}", sans-serif`; +} From c97da29260304939978b090a98c8034f37adf782 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:27 +0100 Subject: [PATCH 11/19] feat(desktop): text segment style, animation, and layout model in the editor --- apps/desktop/src/routes/editor/text-style.tsx | 23 +++++++++++++ apps/desktop/src/routes/editor/text.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/apps/desktop/src/routes/editor/text-style.tsx b/apps/desktop/src/routes/editor/text-style.tsx index dd884200c5f..ef08a87469f 100644 --- a/apps/desktop/src/routes/editor/text-style.tsx +++ b/apps/desktop/src/routes/editor/text-style.tsx @@ -7,6 +7,7 @@ import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding import { BrandColorsDropdown } from "./BrandColorsDropdown"; import { getColorPreviewBorderColor } from "./color-utils"; import { TextInput } from "./TextInput"; +import type { TextAnimation } from "./text"; export const FONT_OPTIONS = [ { value: "System Sans-Serif", label: "System Sans-Serif" }, @@ -39,6 +40,28 @@ export const TEXT_WEIGHT_OPTIONS = [ { label: "Bold", value: 700 }, ]; +export const TEXT_SEGMENT_WEIGHT_OPTIONS = [ + { label: "Light", value: 300 }, + { label: "Regular", value: 400 }, + { label: "Medium", value: 500 }, + { label: "Semibold", value: 600 }, + { label: "Bold", value: 700 }, + { label: "Extra Bold", value: 800 }, + { label: "Black", value: 900 }, +]; + +export const TEXT_ANIMATION_OPTIONS: { + value: TextAnimation; + label: string; +}[] = [ + { value: "none", label: "None" }, + { value: "fade", label: "Fade" }, + { value: "slideUp", label: "Slide up" }, + { value: "slideDown", label: "Slide down" }, + { value: "pop", label: "Pop" }, + { value: "typewriter", label: "Typewriter" }, +]; + export const CAPTION_ANIMATION_OPTIONS = [ { value: "none", label: "None" }, { value: "bounce", label: "Bounce" }, diff --git a/apps/desktop/src/routes/editor/text.ts b/apps/desktop/src/routes/editor/text.ts index a8cc1e449c7..bf791e95b86 100644 --- a/apps/desktop/src/routes/editor/text.ts +++ b/apps/desktop/src/routes/editor/text.ts @@ -7,6 +7,18 @@ export const TEXT_REFERENCE_HEIGHT = 1080; export const TEXT_FONT_SIZE_MIN = 8; export const TEXT_FONT_SIZE_MAX = 400; +export type TextAlign = "left" | "center" | "right"; + +export type TextAnimation = + | "none" + | "fade" + | "slideUp" + | "slideDown" + | "pop" + | "typewriter"; + +export type TextLayout = "overlay" | "fullscreen" | "splitLeft" | "splitRight"; + export type TextSegment = { start: number; end: number; @@ -21,6 +33,17 @@ export type TextSegment = { italic: boolean; color: string; fadeDuration: number; + align: TextAlign; + letterSpacing: number; + lineHeight: number; + opacity: number; + shadow: number; + animationIn: TextAnimation; + animationOut: TextAnimation; + animationInDuration: number; + animationOutDuration: number; + layout: TextLayout; + layoutTransition: number; }; // Picks the starting colour for a new text segment by sampling the composited @@ -98,4 +121,15 @@ export const defaultTextSegment = ( italic: false, color: "#ffffff", fadeDuration: 0.15, + align: "center", + letterSpacing: 0, + lineHeight: 1.2, + opacity: 1, + shadow: 0, + animationIn: "fade", + animationOut: "fade", + animationInDuration: 0.15, + animationOutDuration: 0.15, + layout: "overlay", + layoutTransition: 0.5, }); From 883003c7344b12c359d9b75f9adc6c8271290b95 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:32 +0100 Subject: [PATCH 12/19] feat(desktop): mirror fullscreen text holds in editor timeline math --- .../src/routes/editor/TranscriptPage.tsx | 3 + apps/desktop/src/routes/editor/captions.ts | 18 +++++- apps/desktop/src/routes/editor/context.ts | 10 +++- .../src/routes/editor/timeline-holds.ts | 59 +++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/routes/editor/timeline-holds.ts diff --git a/apps/desktop/src/routes/editor/TranscriptPage.tsx b/apps/desktop/src/routes/editor/TranscriptPage.tsx index dd3b7710e73..6ba60d595d4 100644 --- a/apps/desktop/src/routes/editor/TranscriptPage.tsx +++ b/apps/desktop/src/routes/editor/TranscriptPage.tsx @@ -189,6 +189,7 @@ export function TranscriptPanel() { project.timeline?.transitions ?? [], undefined, "incoming", + project.timeline?.textSegments, ) ?? outputStart; const end = start + defaultDuration; const text = "New caption"; @@ -273,6 +274,7 @@ export function TranscriptPanel() { project.timeline?.transitions ?? [], undefined, "incoming", + project.timeline?.textSegments, ); if (sourceTime === null) return -1; @@ -298,6 +300,7 @@ export function TranscriptPanel() { project.timeline?.segments ?? [], recordingSegments(), project.timeline?.transitions ?? [], + project.timeline?.textSegments, ); if (outputTime === null) return; if (editorState.playing) { diff --git a/apps/desktop/src/routes/editor/captions.ts b/apps/desktop/src/routes/editor/captions.ts index c500e407950..15b44451b2d 100644 --- a/apps/desktop/src/routes/editor/captions.ts +++ b/apps/desktop/src/routes/editor/captions.ts @@ -12,6 +12,12 @@ import { type TimelineSegment, } from "~/utils/tauri"; import { type ClipTransition, clipTimelineOffsets } from "./clip-transitions"; +import type { TextSegment } from "./text"; +import { + effectiveToOutput, + heldTimeBefore, + holdWindows, +} from "./timeline-holds"; export const DEFAULT_CAPTION_MODEL = "best"; export const DEFAULT_WHISPER_CAPTION_MODEL = "small"; export const DEFAULT_CAPTION_LANGUAGE = "auto"; @@ -350,6 +356,7 @@ export function mapSourceTimeToEdited( timelineSegments: TimelineSegment[], recordingSegments: SegmentRecordings[], transitions: ClipTransition[] = [], + textSegments?: readonly TextSegment[], ): number | null { const mappings = buildSourceToEditedMappings( timelineSegments, @@ -358,9 +365,12 @@ export function mapSourceTimeToEdited( ); for (const mapping of mappings) { if (sourceTime >= mapping.sourceStart && sourceTime <= mapping.sourceEnd) { - return ( + // The mapping is in the gapless recording-flow domain; seeks need + // output time, which includes fullscreen-text holds. + return effectiveToOutput( + holdWindows(textSegments), mapping.editedStart + - (sourceTime - mapping.sourceStart) / mapping.timescale + (sourceTime - mapping.sourceStart) / mapping.timescale, ); } } @@ -404,7 +414,11 @@ export function mapEditedTimeToSource( transitions: ClipTransition[] = [], sourceRange?: { start: number; end: number }, overlapPreference: "outgoing" | "incoming" = "outgoing", + textSegments?: readonly TextSegment[], ): number | null { + // Output time includes fullscreen-text holds; the mappings below live in + // the gapless recording-flow domain. + editedTime -= heldTimeBefore(holdWindows(textSegments), editedTime); const mappings = buildSourceToEditedMappings( timelineSegments, recordingSegments, diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index b4aee239ba6..c12d244a134 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -93,6 +93,11 @@ import { sceneWithShotCount, setMotion, } from "./three-d"; +import { + heldTimeBefore, + holdWindows, + totalHeldDuration, +} from "./timeline-holds"; import { getUsedTrackCount, normalizeTrackSegments, @@ -511,6 +516,9 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( const timeline = project.timeline; if (!timeline) return; const segments = timeline.segments; + // The click position is in held-output time; clip offsets + // live in the gapless recording-flow domain. + time -= heldTimeBefore(holdWindows(timeline.textSegments), time); const offsets = clipTimelineOffsets( segments, timeline.transitions ?? [], @@ -1368,7 +1376,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( ? clipTimelineDuration( project.timeline.segments, project.timeline.transitions ?? [], - ) + ) + totalHeldDuration(holdWindows(project.timeline.textSegments)) : props.editorInstance.recordingDuration; type State = { diff --git a/apps/desktop/src/routes/editor/timeline-holds.ts b/apps/desktop/src/routes/editor/timeline-holds.ts new file mode 100644 index 00000000000..5c8abafc7c4 --- /dev/null +++ b/apps/desktop/src/routes/editor/timeline-holds.ts @@ -0,0 +1,59 @@ +import type { TextSegment } from "./text"; + +// Mirrors TimelineConfiguration::hold_windows and friends in +// crates/project/src/configuration.rs — fullscreen text segments pause the +// recording clock, inserting their duration into the output timeline. The +// frontend needs the same arithmetic for the ruler, playhead clamps, clip +// positions and output<->recording conversions. + +export type HoldWindow = [number, number]; + +export function holdWindows( + textSegments: readonly TextSegment[] | null | undefined, +): HoldWindow[] { + if (!textSegments?.length) return []; + const windows = textSegments + .filter( + (segment) => + segment.enabled !== false && + segment.layout === "fullscreen" && + segment.end > segment.start, + ) + .map((segment): HoldWindow => [segment.start, segment.end]) + .sort((a, b) => a[0] - b[0]); + const merged: HoldWindow[] = []; + for (const window of windows) { + const last = merged[merged.length - 1]; + if (last && window[0] <= last[1]) { + last[1] = Math.max(last[1], window[1]); + } else { + merged.push([window[0], window[1]]); + } + } + return merged; +} + +export function totalHeldDuration(holds: HoldWindow[]): number { + return holds.reduce((sum, [start, end]) => sum + (end - start), 0); +} + +export function heldTimeBefore(holds: HoldWindow[], time: number): number { + return holds.reduce( + (sum, [start, end]) => sum + Math.max(0, Math.min(time, end) - start), + 0, + ); +} + +// Places a gapless (recording-flow) timestamp back into output time, landing +// after every hold it passed. +export function effectiveToOutput( + holds: HoldWindow[], + effective: number, +): number { + let output = effective; + for (const [start, end] of holds) { + if (output >= start) output += end - start; + else break; + } + return output; +} From 0c2410100d6db160e3d542edab841480aa5ac928 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:44:40 +0100 Subject: [PATCH 13/19] feat(desktop): timeline ruler scrub, minimap, split snapping, and zoom multi-select --- .../src/routes/editor/Timeline/AudioTrack.tsx | 26 +- .../routes/editor/Timeline/CaptionsTrack.tsx | 31 +- .../src/routes/editor/Timeline/ClipTrack.tsx | 420 +++++++++++++++--- .../routes/editor/Timeline/KeyboardTrack.tsx | 34 +- .../src/routes/editor/Timeline/MaskTrack.tsx | 15 +- .../src/routes/editor/Timeline/Minimap.tsx | 135 ++++++ .../src/routes/editor/Timeline/SceneTrack.tsx | 49 +- .../src/routes/editor/Timeline/TextTrack.tsx | 66 ++- .../routes/editor/Timeline/ThreeDTrack.tsx | 56 ++- .../src/routes/editor/Timeline/Track.tsx | 88 +++- .../src/routes/editor/Timeline/ZoomTrack.tsx | 108 +++-- .../src/routes/editor/Timeline/context.ts | 6 +- .../src/routes/editor/Timeline/index.tsx | 239 ++++++++-- .../routes/editor/Timeline/split-snapping.ts | 66 +++ apps/desktop/src/routes/editor/context.ts | 1 + 15 files changed, 1130 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src/routes/editor/Timeline/Minimap.tsx create mode 100644 apps/desktop/src/routes/editor/Timeline/split-snapping.ts diff --git a/apps/desktop/src/routes/editor/Timeline/AudioTrack.tsx b/apps/desktop/src/routes/editor/Timeline/AudioTrack.tsx index d35fa3f7530..455164e10be 100644 --- a/apps/desktop/src/routes/editor/Timeline/AudioTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/AudioTrack.tsx @@ -10,6 +10,7 @@ import { useTimelineContext } from "./context"; import { SegmentContent, SegmentHandle, + SegmentLabel, SegmentRoot, TrackRoot, useSetPreviewTime, @@ -456,6 +457,7 @@ export function AudioTrack(props: { !segment.enabled && "opacity-50", )} innerClass="ring-emerald-8" + title={segment.name || "Audio"} segment={segment} onMouseDown={(e) => { e.stopPropagation(); @@ -527,12 +529,24 @@ export function AudioTrack(props: { }, )} > -
- - - {segment.name || "Audio"} - -
+ ( +
+ + + {segment.name || "Audio"} + +
+ )} + compact={() => ( +
+ + {segment.name || "Audio"} + +
+ )} + /> Math.min(segment.end, totalDuration()) - segment.start; + // Truncation degrades gracefully, so the same row serves both the + // full and compact tiers; it just clips against a smaller box. + const captionLabel = () => ( +
+ + {segment.text || "Caption"} + +
+ ); + return ( -
-
- - {segment.text || "Caption"} - -
-
+ number | null, ) { if (typeof Path2D === "undefined") return; if (!waveform || waveform.length === 0) return; - const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP); + const duration = Math.max(range.end - range.start, WAVEFORM_SAMPLE_STEP); if (!Number.isFinite(duration) || duration <= 0) return; const nativeSamples = Math.ceil(duration / WAVEFORM_SAMPLE_STEP) + 1; @@ -82,7 +90,9 @@ function createWaveformPath( const path = new Path2D(); path.moveTo(0, 1); - const amplitudeAt = (time: number) => { + const amplitudeAt = (outputTime: number) => { + const time = sourceTimeAt(outputTime); + if (time === null) return 0; const index = Math.floor(time * 10); const sample = waveform[index]; const db = @@ -97,10 +107,10 @@ function createWaveformPath( const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25); for (let i = 0; i <= numSamples; i++) { - const time = segment.start + i * timeStep; - const normalizedX = (time - segment.start) / duration; + const time = range.start + i * timeStep; + const normalizedX = (time - range.start) / duration; const prevTime = time - timeStep; - const prevX = Math.max(0, (prevTime - segment.start) / duration); + const prevX = Math.max(0, (prevTime - range.start) / duration); const y = 1 - amplitudeAt(time); const prevY = 1 - amplitudeAt(prevTime); const cpX1 = prevX + controlStep / 2; @@ -109,7 +119,7 @@ function createWaveformPath( } const closingX = - (segment.end + WAVEFORM_PADDING_SECONDS - segment.start) / duration; + (range.end + WAVEFORM_PADDING_SECONDS - range.start) / duration; path.lineTo(closingX, 1); path.closePath(); @@ -138,6 +148,7 @@ function WaveformCanvas(props: { micWaveform?: number[]; segment: { start: number; end: number }; segmentOffset: number; + holds: ReadonlyArray<[number, number]>; }) { const { project, editorState } = useEditorContext(); const { width } = useSegmentContext(); @@ -153,19 +164,40 @@ function WaveformCanvas(props: { const ctx = canvas.getContext("2d"); if (!ctx) return; - const segmentDuration = props.segment.end - props.segment.start; + // Hold windows relative to the clip's box; the box is stretched across + // them, so the canvas spans output time, not source time. + const holds = props.holds.map(([start, end]): [number, number] => [ + start - props.segmentOffset, + end - props.segmentOffset, + ]); + const heldDuration = holds.reduce( + (sum, [start, end]) => sum + end - start, + 0, + ); + const outputDuration = + props.segment.end - props.segment.start + heldDuration; const fullSegmentWidth = width(); - if (fullSegmentWidth < 1 || segmentDuration <= 0) { + if (fullSegmentWidth < 1 || outputDuration <= 0) { return; } + const sourceTimeAt = (outputTime: number): number | null => { + let held = 0; + for (const [start, end] of holds) { + if (outputTime >= end) held += end - start; + else if (outputTime > start) return null; + else break; + } + return props.segment.start + outputTime - held; + }; + const useVirtualization = fullSegmentWidth > MAX_CANVAS_WIDTH; let canvasWidth: number; let leftOffsetPx: number; let renderWidth: number; - let renderSegment: { start: number; end: number }; + let renderRange: { start: number; end: number }; if (useVirtualization) { const viewportWidth = timelineBounds.width ?? 800; @@ -174,7 +206,7 @@ function WaveformCanvas(props: { const viewEnd = viewStart + transform.zoom; const segStart = props.segmentOffset; - const segEnd = segStart + segmentDuration; + const segEnd = segStart + outputDuration; const visibleStart = Math.max(viewStart, segStart); const visibleEnd = Math.min(viewEnd, segEnd); @@ -189,7 +221,7 @@ function WaveformCanvas(props: { const visibleStartInSegment = visibleStart - segStart; const visibleEndInSegment = visibleEnd - segStart; - const pxPerSec = fullSegmentWidth / segmentDuration; + const pxPerSec = fullSegmentWidth / outputDuration; const visibleWidthPx = Math.min( (visibleEndInSegment - visibleStartInSegment) * pxPerSec, viewportWidth + 200, @@ -201,24 +233,24 @@ function WaveformCanvas(props: { ); leftOffsetPx = visibleStartInSegment * pxPerSec; renderWidth = visibleWidthPx; - renderSegment = { - start: props.segment.start + visibleStartInSegment, - end: props.segment.start + visibleEndInSegment, + renderRange = { + start: visibleStartInSegment, + end: visibleEndInSegment, }; } else { canvasWidth = Math.max(Math.ceil(fullSegmentWidth), 1); leftOffsetPx = 0; renderWidth = fullSegmentWidth; - renderSegment = { - start: props.segment.start, - end: props.segment.end, - }; + renderRange = { start: 0, end: outputDuration }; } const micScale = gainToScale(project.audio.micVolumeDb); const systemScale = gainToScale(project.audio.systemVolumeDb); - const renderKey = `${canvasWidth}-${renderSegment.start.toFixed(2)}-${renderSegment.end.toFixed(2)}-${micScale.toFixed(2)}-${systemScale.toFixed(2)}`; + const holdsKey = holds + .map(([start, end]) => `${start.toFixed(2)}:${end.toFixed(2)}`) + .join(","); + const renderKey = `${canvasWidth}-${props.segment.start.toFixed(2)}-${renderRange.start.toFixed(2)}-${renderRange.end.toFixed(2)}-${holdsKey}-${micScale.toFixed(2)}-${systemScale.toFixed(2)}`; if (renderKey === lastRenderKey) { return; } @@ -241,7 +273,12 @@ function WaveformCanvas(props: { color: string, gain?: number, ) => { - const path = createWaveformPath(renderSegment, waveform, numSamples); + const path = createWaveformPath( + renderRange, + waveform, + numSamples, + sourceTimeAt, + ); if (!path) return; const scale = gainToScale(gain); if (scale <= 0) return; @@ -272,6 +309,8 @@ function WaveformCanvas(props: { editorState.timeline.transform.zoom; props.segment.start; props.segment.end; + props.segmentOffset; + props.holds; props.micWaveform; props.systemWaveform; project.audio.micVolumeDb; @@ -311,6 +350,89 @@ function WaveformCanvas(props: { ); } +// The speed chip is rendered once per label tier so an open popover survives +// the segment shrinking past a tier boundary; the menu itself lives here so +// it isn't duplicated per tier. +function ClipSpeedControl(props: { + timescale: number; + speedAudioMode?: ClipSpeedAudioMode | null; + open: boolean; + onOpenChange: (open: boolean) => void; + triggerClass?: string; + onSetTimescale: (timescale: number) => void; + onSetSpeedAudioMode: (mode: ClipSpeedAudioMode) => void; +}) { + return ( + + event.stopPropagation()} + > + + {props.timescale}x + + + event.stopPropagation()} + class="z-50 flex w-max flex-col gap-1.5 rounded-xl border border-gray-3 bg-gray-1 p-2 text-gray-12 shadow-xl outline-hidden animate-in fade-in slide-in-from-bottom-2" + > +
+ {[0.25, 0.5, 1, 1.5, 2, 4, 8].map((mult) => ( + + ))} +
+ +
+ {( + [ + ["mute", "Mute"], + ["maintainPitch", "Maintain pitch"], + ["matchSpeed", "Match speed"], + ] as const + ).map(([value, label]) => ( + + ))} +
+
+
+
+
+ ); +} + export function ClipTrack( props: Pick, "ref"> & { handleUpdatePlayhead: (e: MouseEvent) => void; @@ -381,15 +503,42 @@ export function ClipTrack( }; }; + // Fullscreen text segments pause the recording clock, stretching the clip + // that contains them across the held window on the output timeline. + const heldWindows = createMemo(() => + holdWindows(project.timeline?.textSegments), + ); + + const selectedHoldWindows = createMemo(() => { + const selection = editorState.timeline.selection; + if (selection?.type !== "text") return null; + const texts = project.timeline?.textSegments; + if (!texts) return null; + const windows = selection.indices + .map((index) => texts[index]) + .filter( + (segment) => + segment && + segment.enabled !== false && + segment.layout === "fullscreen", + ) + .map((segment): [number, number] => [segment.start, segment.end]); + return windows.length > 0 ? windows : null; + }); + const visibleSegmentIndices = createMemo(() => { const segs = segments(); const offsets = segmentOffsets(); + const holds = heldWindows(); const draggedIndex = transitionDrag()?.index; const visible: number[] = []; for (let i = 0; i < segs.length; i++) { const seg = segs[i]; - const segStart = offsets[i]; - const segEnd = segStart + (seg.end - seg.start) / seg.timescale; + const segStart = effectiveToOutput(holds, offsets[i]); + const segEnd = effectiveToOutput( + holds, + offsets[i] + (seg.end - seg.start) / seg.timescale, + ); if (i === draggedIndex || isSegmentVisible(segStart, segEnd)) { visible.push(i); } @@ -414,6 +563,10 @@ export function ClipTrack( const split = () => editorState.timeline.interactMode === "split"; + createEffect(() => { + if (!split()) setEditorState("timeline", "splitPreview", null); + }); + function selectClip(currentIndex: number, event: MouseEvent) { const selection = editorState.timeline.selection; const isMac = navigator.platform.toUpperCase().includes("MAC"); @@ -450,12 +603,29 @@ export function ClipTrack( setEditorState("timeline", "hoveredTrack", "clip")} - onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} + onMouseLeave={() => { + setEditorState("timeline", "hoveredTrack", null); + setEditorState("timeline", "splitPreview", null); + }} > {(segmentIndex) => { const i = segmentIndex; const segment = () => segments()[i()]; + const [speedOpen, setSpeedOpen] = createSignal(false); + + const clipName = () => + hasMultipleRecordingSegments() + ? `Clip ${segment().recordingSegment}` + : "Clip"; + + const clipTitle = () => { + const seg = segment(); + const parts = [clipName(), formatTime(seg.end - seg.start)]; + if (seg.timescale !== 1) parts.push(`${seg.timescale}x`); + return parts.join(" · "); + }; + const [startHandleDrag, setStartHandleDrag] = createSignal { + const { start, end } = relativeSegment(); + return heldWindows() + .map(([holdStart, holdEnd]): [number, number] => [ + Math.max(holdStart, start), + Math.min(holdEnd, end), + ]) + .filter(([holdStart, holdEnd]) => holdEnd > holdStart); + }); + const segmentX = useSegmentTranslateX(relativeSegment); const segmentWidth = useSegmentWidth(relativeSegment); + const splitTimeAt = (e: { + clientX: number; + altKey: boolean; + currentTarget: HTMLDivElement; + }) => { + const rect = e.currentTarget.getBoundingClientRect(); + const seg = relativeSegment(); + const raw = seg.start + (e.clientX - rect.left) * secsPerPixel(); + if (e.altKey) return { time: raw, snapped: null }; + return snapSplitTime( + raw, + seg.start, + seg.end, + SPLIT_SNAP_PX * secsPerPixel(), + project.timeline, + editorState.playbackTime, + ); + }; + const segmentRecording = (s = i()) => editorInstance.recordings.segments[ segments()[s].recordingSegment ?? 0 @@ -617,7 +823,20 @@ export function ClipTrack( isSelected() ? "border-gray-12" : "border-transparent", )} innerClass="ring-blue-9" + title={clipTitle()} segment={relativeSegment()} + onMouseMove={(e) => { + if (editorState.timeline.interactMode !== "split") return; + const result = splitTimeAt(e); + setEditorState("timeline", "splitPreview", { + time: result.time, + snapped: result.snapped !== null, + }); + }} + onMouseLeave={() => { + if (editorState.timeline.splitPreview) + setEditorState("timeline", "splitPreview", null); + }} onMouseDown={(e) => { e.stopPropagation(); if (e.button !== 0) return; @@ -629,17 +848,10 @@ export function ClipTrack( return; if (editorState.timeline.interactMode === "split") { - const rect = e.currentTarget.getBoundingClientRect(); - const fraction = (e.clientX - rect.left) / rect.width; - const seg = segment(); - - const splitTime = - (fraction * (seg.end - seg.start)) / seg.timescale; - - projectActions.splitClipSegment( - prevDuration() + splitTime, - i(), - ); + // The box is in output time (it stretches across any + // held windows); splitClipSegment converts back to the + // recording-flow domain itself. + projectActions.splitClipSegment(splitTimeAt(e).time, i()); } else { const index = i(); const initialTransition = getClipTransition( @@ -733,11 +945,61 @@ export function ClipTrack( micWaveform={micWaveform()} systemWaveform={systemAudioWaveform()} segment={segment()} - segmentOffset={prevDuration()} + segmentOffset={relativeSegment().start} + holds={segmentHolds()} /> )} - + + + + {(hold) => { + // Light up when the fullscreen text causing this hold + // is selected, so cause and effect read as one thing. + const causeSelected = () => + selectedHoldWindows()?.some( + ([start, end]) => start < hold[1] && end > hold[0], + ) ?? false; + const holdWidth = () => + (hold[1] - hold[0]) / secsPerPixel(); + return ( +
+ + = 64}> + + Paused + + +
+ ); + }} +
0 && !transitionAt(i())}> + + - setEditorState("timeline", "selection", null) - } - leftIcon={} + variant="danger" + onClick={() => { + projectActions.deleteZoomSegments( + value().segments.map((s) => s.index), + ); + }} + leftIcon={} > - Done + Delete - - {value().segments.length} zoom{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - - { - projectActions.deleteZoomSegments( - value().segments.map((s) => s.index), - ); - }} - leftIcon={} + + } > - Delete - + + {(item) => ( +
+ +
+ )} +
+ - - - {(item, index) => ( -
- -
- )} -
- - } - > - - {(item) => ( -
- -
- )} -
-
- - )} + ); + }} { @@ -1705,79 +1754,6 @@ export function ConfigSidebar() { )} - { - const clipSelection = selection(); - if (clipSelection.type !== "clip") return; - - const segments = clipSelection.indices - .map((idx) => ({ - segment: project.timeline?.segments?.[idx], - index: idx, - })) - .filter( - (s): s is { segment: TimelineSegment; index: number } => - s.segment !== undefined, - ); - - if (segments.length === 0) return; - return { selection: clipSelection, segments }; - })()} - > - {(value) => ( - - {(firstSegment) => ( - 1} - fallback={ - - } - > -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} clip{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - const indices = value().selection.indices; - - // Delete segments in reverse order to maintain indices - [...indices] - .sort((a, b) => b - a) - .forEach((idx) => { - projectActions.deleteClipSegment(idx); - }); - }} - leftIcon={} - > - Delete - -
-
-
- )} -
- )} -
)} @@ -3488,12 +3464,161 @@ function HexColorInput(props: { ); } +function TextStyleSelect(props: { + options: { label: string; value: T }[]; + value: T; + onChange: (value: T) => void; + fallbackLabel?: (value: T) => string; +}) { + const selected = () => + props.options.find((option) => option.value === props.value) ?? { + label: props.fallbackLabel?.(props.value) ?? String(props.value), + value: props.value, + }; + + return ( + { + if (option) props.onChange(option.value); + }} + itemComponent={(selectItemProps) => ( + + as={KSelect.Item} + item={selectItemProps.item} + > + + {selectItemProps.item.rawValue.label} + + + + + + )} + > + + class="truncate"> + {(state) => state.selectedOption()?.label ?? selected().label} + + + + + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-52" + as={KSelect.Listbox} + /> + + + + ); +} + +const TEXT_PRESET_HOVER_FX: Record = { + fade: "group-hover:opacity-70", + slideUp: "group-hover:-translate-y-1", + slideDown: "group-hover:translate-y-1", + pop: "group-hover:scale-110", + typewriter: "", + none: "", +}; + +function TextPresetCard(props: { + preset: TextPreset; + active: boolean; + onApply: () => void; +}) { + const style = () => props.preset.style; + const stackCss = () => + style() + .fontStack.map((family) => + ["sans-serif", "serif", "monospace"].includes(family) + ? family + : `"${family}"`, + ) + .join(", "); + + return ( + + ); +} + +// The renderer also supports splitLeft/splitRight takeovers; only these two +// are exposed for now. +const TEXT_LAYOUT_OPTIONS: { + value: TextLayout; + label: string; + icon: ValidComponent; +}[] = [ + { value: "overlay", label: "Overlay", icon: IconLucideBoxSelect }, + { value: "fullscreen", label: "Fullscreen", icon: IconLucideMaximize }, +]; + +const TEXT_LAYOUT_CENTERS: Partial< + Record +> = { + fullscreen: { x: 0.5, y: 0.5 }, +}; + +const TEXT_ALIGN_OPTIONS: { value: TextAlign; icon: ValidComponent }[] = [ + { value: "left", icon: IconLucideAlignLeft }, + { value: "center", icon: IconLucideAlignCenter }, + { value: "right", icon: IconLucideAlignRight }, +]; + function TextSegmentConfig(props: { segmentIndex: number; segment: TextSegment; brandColorSwatches: OrganizationBrandColorSwatch[]; }) { const { setProject } = useEditorContext(); + const [installedFonts] = createResource(listSystemFonts, { + initialValue: [], + }); const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(Number.isFinite(value) ? value : min, min), max); @@ -3509,6 +3634,24 @@ function TextSegmentConfig(props: { ); }; + const activePresetId = createMemo(() => + matchTextPreset(props.segment, installedFonts()), + ); + + const setAnimationDuration = ( + key: "animationInDuration" | "animationOutDuration", + value: number, + ) => + updateSegment((segment) => { + segment[key] = clampNumber(value, 0, 3); + // Old builds only know fadeDuration; keep it tracking the slower + // edge so a project opened there still fades sensibly. + segment.fadeDuration = Math.max( + segment.animationInDuration ?? 0.15, + segment.animationOutDuration ?? 0.15, + ); + }); + return (
- }> - - updateSegment((segment) => { - const newFontSize = clampNumber( - value, - TEXT_FONT_SIZE_MIN, - TEXT_FONT_SIZE_MAX, - ); - const oldFontSize = segment.fontSize || 48; - const scale = newFontSize / oldFontSize; - - segment.fontSize = newFontSize; - - // Scale the box with the font so line wrapping is - // preserved; keep the top edge fixed since the renderer - // anchors text at the top of the box (the canvas overlay - // re-hugs the box to the exact glyph bounds when visible). - if (segment.size && segment.center) { - const topEdge = segment.center.y - segment.size.y / 2; - segment.size.x = Math.min(segment.size.x * scale, 1); - segment.size.y = segment.size.y * scale; - segment.center.y = topEdge + segment.size.y / 2; - } - }) - } - minValue={TEXT_FONT_SIZE_MIN} - maxValue={TEXT_FONT_SIZE_MAX} - step={1} - /> + }> +
+
+ + {(option) => ( + + )} + +
+ +

+ Pauses the video while the text is shown, then resumes where it + left off. +

+
+ +
+ Screen transition + + updateSegment((segment) => { + segment.layoutTransition = clampNumber(value, 0.1, 1.5); + }) + } + minValue={0.1} + maxValue={1.5} + step={0.05} + formatTooltip="s" + /> +
+
+
+
+ }> +
+ + {(preset) => ( + + updateSegment((segment) => + applyTextPreset(segment, preset, installedFonts()), + ) + } + /> + )} + +
- }> + }>
- { - if (!value) return; + updateSegment((segment) => { - segment.fontWeight = value.value; - }); - }} - itemComponent={(selectItemProps) => ( - - as={KSelect.Item} - item={selectItemProps.item} - > - - {selectItemProps.item.rawValue.label} - - - - - - )} - > - - class="truncate"> - {(state) => { - const selected = state.selectedOption(); - if (selected) return selected.label; - const weight = props.segment.fontWeight; - const option = [ - { label: "Normal", value: 400 }, - { label: "Medium", value: 500 }, - { label: "Bold", value: 700 }, - ].find((o) => o.value === weight); - if (option) return option.label; - if (weight != null) return `Custom (${weight})`; - return "Normal"; - }} - - - - - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-40" - as={KSelect.Listbox} - /> - - - - -
- Italic - + segment.fontFamily = family; + }) + } + /> +
+
+ + updateSegment((segment) => { + segment.fontWeight = value; + }) + } + fallbackLabel={(value) => `Custom (${value})`} + /> +
+ +
+
+ Size + updateSegment((segment) => { - segment.italic = value; + const newFontSize = clampNumber( + value, + TEXT_FONT_SIZE_MIN, + TEXT_FONT_SIZE_MAX, + ); + const oldFontSize = segment.fontSize || 48; + const scale = newFontSize / oldFontSize; + + segment.fontSize = newFontSize; + + // Scale the box with the font so line wrapping is + // preserved; keep the top edge fixed since the renderer + // anchors text at the top of the box (the canvas overlay + // re-hugs the box to the exact glyph bounds when visible). + if (segment.size && segment.center) { + const topEdge = segment.center.y - segment.size.y / 2; + segment.size.x = Math.min(segment.size.x * scale, 1); + segment.size.y = segment.size.y * scale; + segment.center.y = topEdge + segment.size.y / 2; + } }) } + minValue={TEXT_FONT_SIZE_MIN} + maxValue={TEXT_FONT_SIZE_MAX} + step={1} />
- }> - - updateSegment((segment) => { - segment.color = value; - }) - } - /> + }> +
+
+ + {(option) => ( + + )} + +
+
+ Line height + + updateSegment((segment) => { + segment.lineHeight = clampNumber(value, 0.8, 2); + }) + } + minValue={0.8} + maxValue={2} + step={0.05} + /> +
+
+ Letter spacing + + updateSegment((segment) => { + segment.letterSpacing = clampNumber(value, -2, 20); + }) + } + minValue={-2} + maxValue={20} + step={0.5} + formatTooltip="px" + /> +
+
- }> - - updateSegment((segment) => { - segment.fadeDuration = clampNumber(value, 0, 1); - }) - } - minValue={0} - maxValue={1} - step={0.01} - formatTooltip="s" - /> + }> +
+ + updateSegment((segment) => { + segment.color = value; + }) + } + /> +
+ Opacity + + updateSegment((segment) => { + segment.opacity = clampNumber(value, 0, 1); + }) + } + minValue={0} + maxValue={1} + step={0.01} + /> +
+
+ Shadow + + updateSegment((segment) => { + segment.shadow = clampNumber(value, 0, 1); + }) + } + minValue={0} + maxValue={1} + step={0.01} + /> +
+
+
+ }> +
+
+ In + + updateSegment((segment) => { + segment.animationIn = value; + }) + } + /> + + + setAnimationDuration("animationInDuration", value) + } + minValue={0} + maxValue={3} + step={0.05} + formatTooltip="s" + /> + +
+
+ Out + + updateSegment((segment) => { + segment.animationOut = value; + }) + } + /> + + + setAnimationDuration("animationOutDuration", value) + } + minValue={0} + maxValue={3} + step={0.05} + formatTooltip="s" + /> + +
+
); @@ -5116,32 +5429,57 @@ function Camera3DSegmentConfig(props: { ); } +// Maps a zoom segment's start (held-output time on the edited timeline) to +// the recording-segment file and the time within it whose frame is on screen +// at that moment. Split/trimmed timelines mean neither can be read off the +// zoom segment directly. +function zoomPreviewSource( + timeline: + | { + segments: TimelineSegment[]; + transitions?: ClipTransition[]; + textSegments?: TextSegment[]; + } + | null + | undefined, + editedTime: number, +): { recordingSegment: number; sourceTime: number } { + const gapless = + editedTime - + heldTimeBefore(holdWindows(timeline?.textSegments), editedTime); + return ( + clipSourceTimeAt( + timeline?.segments ?? [], + timeline?.transitions ?? [], + gapless, + ) ?? { recordingSegment: 0, sourceTime: gapless } + ); +} + +// The mapping memos return fresh objects; compare by value so unrelated +// timeline edits don't restart the preview