From a044287ee47e692b9f1ea35916b1f6012991086f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:55 +0100 Subject: [PATCH 1/6] feat: add per-layer color correction config --- crates/project/src/configuration.rs | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 1e5234c82d..16dc2dc5f7 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -449,6 +449,82 @@ impl Default for BackgroundBlurConfig { } } +/// Parametric color grade for a single layer (screen or camera). Every field +/// except `intensity` has 0 as its identity, so a default struct renders +/// exactly like no grade at all. Adjustment fields are normalized: -1..1 for +/// bipolar controls, 0..1 for unipolar ones. +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct ColorCorrection { + /// UI preset id ("none", "cinematic", ..., or "custom"). The renderer + /// ignores this; the numeric fields below are the source of truth. + pub preset: String, + /// 0..1 master strength applied to every adjustment except `grain`, + /// which has its own dedicated control. + pub intensity: f32, + /// -1..1, full scale is ±1.5 stops. + pub exposure: f32, + /// -1..1 around a mid-gray pivot. + pub contrast: f32, + /// -1..1; -1 is grayscale. + pub saturation: f32, + /// -1..1; positive warms, negative cools. + pub temperature: f32, + /// -1..1; positive shifts magenta, negative green. + pub tint: f32, + /// 0..1 lifted-blacks film fade. + pub fade: f32, + /// -1..1 teal-shadows/orange-highlights split toning (negative reverses). + pub split_tone: f32, + /// 0..1 edge darkening within the layer's own rect. + pub vignette: f32, + /// 0..1 animated film grain. + pub grain: f32, +} + +impl ColorCorrection { + pub const PRESET_NONE: &'static str = "none"; +} + +impl Default for ColorCorrection { + fn default() -> Self { + Self { + preset: Self::PRESET_NONE.to_string(), + intensity: 1.0, + exposure: 0.0, + contrast: 0.0, + saturation: 0.0, + temperature: 0.0, + tint: 0.0, + fade: 0.0, + split_tone: 0.0, + vignette: 0.0, + grain: 0.0, + } + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct ColorCorrectionConfiguration { + pub screen: ColorCorrection, + pub camera: ColorCorrection, + /// Whether the screen grade also covers the rendered cursor. On by + /// default so the pointer reads as part of the graded footage; off keeps + /// it crisp for legibility over vignettes and grain. + pub grade_cursor: bool, +} + +impl Default for ColorCorrectionConfiguration { + fn default() -> Self { + Self { + screen: ColorCorrection::default(), + camera: ColorCorrection::default(), + grade_cursor: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", default)] pub struct Camera { @@ -1966,6 +2042,11 @@ pub struct ProjectConfiguration { pub screen_motion_blur: f32, #[serde(default)] pub screen_movement_spring: ScreenMovementSpring, + /// Per-layer cinematic color grades. Field-level default keeps old + /// project files (and old saved presets) deserializing to the identity + /// grade. + #[serde(default)] + pub color_correction: ColorCorrectionConfiguration, /// How text segment font sizes are interpreted. 0 (legacy): the renderer /// multiplied `font_size` by `size.y / 0.2`, coupling glyph size to the /// box. 1: `font_size` alone determines glyph size (1080p-relative); @@ -2006,6 +2087,7 @@ impl Default for ProjectConfiguration { hidden_text_segments: Default::default(), screen_motion_blur: Self::default_screen_motion_blur(), screen_movement_spring: Default::default(), + color_correction: Default::default(), text_size_version: TEXT_SIZE_VERSION, } } From 50a3cdb0e9587cc192b5ba5d4f8c29a9060c1c6a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:55 +0100 Subject: [PATCH 2/6] feat: apply color grades in the composite and background passes --- crates/rendering/src/composite_frame.rs | 87 ++++++++ crates/rendering/src/layers/color_grade.rs | 188 ++++++++++++++++++ crates/rendering/src/layers/mod.rs | 2 + crates/rendering/src/lib.rs | 63 +++++- crates/rendering/src/shaders/color-grade.wgsl | 105 ++++++++++ .../src/shaders/composite-video-frame.wgsl | 108 +++++++++- 6 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 crates/rendering/src/layers/color_grade.rs create mode 100644 crates/rendering/src/shaders/color-grade.wgsl diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 8a56e705f8..559eef5033 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -92,6 +92,14 @@ pub struct CompositeVideoFrameUniforms { /// squares its top corners against decorative frame chrome with /// `[0, 0, 1, 1]`. pub corner_radii: [f32; 4], + /// Color grade: (exposure in stops, contrast, saturation, temperature). + pub color_adjust_a: [f32; 4], + /// Color grade: (tint, fade, split_tone, vignette). + pub color_adjust_b: [f32; 4], + /// (grain amount, per-frame grain seed, grade-active flag, full-frame + /// vignette flag). The active flag lets the shader skip the whole color + /// pass in one uniform branch when the layer has no grade. + pub grain_params: [f32; 4], } impl Default for CompositeVideoFrameUniforms { @@ -119,6 +127,85 @@ impl Default for CompositeVideoFrameUniforms { _padding1: [0.0; 3], border_color: [0.0, 0.0, 0.0, 0.0], corner_radii: [1.0; 4], + color_adjust_a: [0.0; 4], + color_adjust_b: [0.0; 4], + grain_params: [0.0; 4], + } + } +} + +/// The three uniform vec4s that drive the shader's color pass, derived from a +/// layer's [`cap_project::ColorCorrection`]. Intensity scaling happens here so +/// the shader never needs to know about it, and every field is clamped so a +/// hand-edited config can't push the shader outside its designed ranges. +#[derive(Debug, Clone, Copy)] +pub struct ColorGradeUniformParams { + pub color_adjust_a: [f32; 4], + pub color_adjust_b: [f32; 4], + pub grain_params: [f32; 4], +} + +impl ColorGradeUniformParams { + pub const IDENTITY: Self = Self { + color_adjust_a: [0.0; 4], + color_adjust_b: [0.0; 4], + grain_params: [0.0; 4], + }; + + /// Mirrors the shader's own gate: `grain_params.z` is the active flag + /// `from_config` sets when any adjustment survives clamping and intensity + /// scaling. + pub fn is_active(&self) -> bool { + self.grain_params[2] > 0.5 + } + + /// `full_frame_vignette` selects the vignette's coordinate space: + /// the screen uses the full output frame (so the display card and the + /// graded background share one continuous vignette field), while the + /// camera vignettes within its own card. + pub fn from_config( + config: &cap_project::ColorCorrection, + frame_number: u32, + full_frame_vignette: bool, + ) -> Self { + let intensity = config.intensity.clamp(0.0, 1.0); + let exposure_stops = config.exposure.clamp(-1.0, 1.0) * 1.5 * intensity; + let contrast = config.contrast.clamp(-1.0, 1.0) * intensity; + let saturation = config.saturation.clamp(-1.0, 1.0) * intensity; + let temperature = config.temperature.clamp(-1.0, 1.0) * intensity; + let tint = config.tint.clamp(-1.0, 1.0) * intensity; + let fade = config.fade.clamp(0.0, 1.0) * intensity; + let split_tone = config.split_tone.clamp(-1.0, 1.0) * intensity; + let vignette = config.vignette.clamp(0.0, 1.0) * intensity; + let grain = config.grain.clamp(0.0, 1.0); + + let active = [ + exposure_stops, + contrast, + saturation, + temperature, + tint, + fade, + split_tone, + vignette, + grain, + ] + .iter() + .any(|v| v.abs() > 1e-4); + + // Cycling seed keeps grain animated while staying deterministic per + // frame number, so preview and export always match. + let grain_seed = (frame_number % 600) as f32; + + Self { + color_adjust_a: [exposure_stops, contrast, saturation, temperature], + color_adjust_b: [tint, fade, split_tone, vignette], + grain_params: [ + grain, + grain_seed, + if active { 1.0 } else { 0.0 }, + if full_frame_vignette { 1.0 } else { 0.0 }, + ], } } } diff --git a/crates/rendering/src/layers/color_grade.rs b/crates/rendering/src/layers/color_grade.rs new file mode 100644 index 0000000000..f23bb69b79 --- /dev/null +++ b/crates/rendering/src/layers/color_grade.rs @@ -0,0 +1,188 @@ +use bytemuck::{Pod, Zeroable}; +use wgpu::util::DeviceExt; + +use crate::ProjectUniforms; + +/// Full-frame pass that applies the screen's color grade (including grain) +/// to the background canvas, so the backdrop and the display card read as one +/// graded scene. Runs between the background (+ its blur) and the display +/// layer, and only when the screen grade is active — an ungraded project +/// skips the pass entirely. +pub struct ColorGradeLayer { + active: bool, + uniforms_buffer: wgpu::Buffer, + pipeline: ColorGradePipeline, +} + +impl ColorGradeLayer { + pub fn new(device: &wgpu::Device) -> Self { + Self { + active: false, + uniforms_buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("ColorGrade Uniform Buffer"), + contents: bytemuck::cast_slice(&[ColorGradeUniforms::default()]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }), + pipeline: ColorGradePipeline::new(device), + } + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn prepare(&mut self, queue: &wgpu::Queue, uniforms: &ProjectUniforms) { + // Reuses the exact params the display card was given: grain and + // vignette only stay continuous across the card edge if both passes + // grade with identical values. + let params = uniforms.screen_color_grade; + self.active = params.is_active(); + if !self.active { + return; + } + + queue.write_buffer( + &self.uniforms_buffer, + 0, + bytemuck::cast_slice(&[ColorGradeUniforms { + color_adjust_a: params.color_adjust_a, + color_adjust_b: params.color_adjust_b, + grain_params: params.grain_params, + }]), + ); + } + + pub fn render( + &self, + pass: &mut wgpu::RenderPass<'_>, + device: &wgpu::Device, + source_texture: &wgpu::TextureView, + ) { + pass.set_pipeline(&self.pipeline.render_pipeline); + pass.set_bind_group( + 0, + &self + .pipeline + .bind_group(device, &self.uniforms_buffer, source_texture), + &[], + ); + pass.draw(0..3, 0..1); + } +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] +struct ColorGradeUniforms { + color_adjust_a: [f32; 4], + color_adjust_b: [f32; 4], + grain_params: [f32; 4], +} + +struct ColorGradePipeline { + bind_group_layout: wgpu::BindGroupLayout, + render_pipeline: wgpu::RenderPipeline, +} + +impl ColorGradePipeline { + fn new(device: &wgpu::Device) -> Self { + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("color-grade Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Color Grade Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/color-grade.wgsl").into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Color Grade Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Color Grade Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[], + zero_initialize_workgroup_memory: false, + }, + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[], + zero_initialize_workgroup_memory: false, + }, + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + Self { + bind_group_layout, + render_pipeline, + } + } + + fn bind_group( + &self, + device: &wgpu::Device, + uniform_buffer: &wgpu::Buffer, + texture_view: &wgpu::TextureView, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ColorGrade Bind Group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + ], + }) + } +} diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index cb96d8b4d5..6237813d10 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -3,6 +3,7 @@ mod blur; mod camera; mod camera3d; mod captions; +mod color_grade; mod cursor; mod display; mod frame; @@ -67,6 +68,7 @@ pub use blur::*; pub use camera::*; pub use camera3d::*; pub use captions::*; +pub use color_grade::*; pub use cursor::*; pub use display::*; pub use frame::*; diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index c3167f012f..eb625d23ad 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -5,7 +5,7 @@ use cap_project::{ FrameStyle, ProjectConfiguration, RecordingMeta, SceneMode, StudioRecordingMeta, TimelineFrameMapping, TimelineSource, XY, }; -use composite_frame::CompositeVideoFrameUniforms; +use composite_frame::{ColorGradeUniformParams, CompositeVideoFrameUniforms}; use core::f64; use cursor_interpolation::{ InterpolatedCursorPosition, interpolate_cursor, interpolate_cursor_with_click_spring, @@ -18,8 +18,8 @@ use frame_pipeline::{ use futures::future::OptionFuture; use layers::{ Background, BackgroundLayer, BlurLayer, Camera3DBlurKind, Camera3DLayer, CameraLayer, - CaptionsLayer, CursorLayer, DisplayLayer, FrameLayer, KeyboardLayer, MaskLayer, NotchLayer, - NotchUniforms, TextLayer, + CaptionsLayer, ColorGradeLayer, CursorLayer, DisplayLayer, FrameLayer, KeyboardLayer, + MaskLayer, NotchLayer, NotchUniforms, TextLayer, }; use specta::Type; use spring_mass_damper::SpringMassDamperSimulationConfig; @@ -2251,6 +2251,10 @@ pub struct ProjectUniforms { /// The recording device's physical notch, redrawn over the capture; /// `None` when the overlay is off or the recording has no notch. pub notch: Option, + /// The screen grade's uniform params, shared verbatim by the display card + /// and the background grade pass so grain and vignette stay continuous + /// across the card edge. + screen_color_grade: ColorGradeUniformParams, /// Final placement of the outer display card (chrome included) in output /// px. Equals `display.target_bounds` when no frame is active. display_outer_bounds: [f32; 4], @@ -3286,6 +3290,17 @@ impl ProjectUniforms { let current_recording_time = segment_frames.recording_time; let prev_recording_time = (segment_frames.recording_time - 1.0 / fps_f32).max(0.0); + let screen_color_grade = ColorGradeUniformParams::from_config( + &project.color_correction.screen, + frame_number, + true, + ); + let camera_color_grade = ColorGradeUniformParams::from_config( + &project.color_correction.camera, + frame_number, + false, + ); + let cursor_stop_time = project .cursor .stop_movement_in_last_seconds @@ -3719,6 +3734,10 @@ impl ProjectUniforms { _padding1: [0.0; 3], border_color, corner_radii: [1.0; 4], + // Chrome is decoration, not video: never graded. + color_adjust_a: [0.0; 4], + color_adjust_b: [0.0; 4], + grain_params: [0.0; 4], }, style: frame.style, theme: frame.theme, @@ -3781,6 +3800,11 @@ impl ProjectUniforms { border_color: [0.0; 4], frame_size: [1.0, 1.0], crop_bounds: [0.0, 0.0, 1.0, 1.0], + // The notch redraw is hardware, not video: never + // graded. + color_adjust_a: [0.0; 4], + color_adjust_b: [0.0; 4], + grain_params: [0.0; 4], }, raster_size: [unzoomed.full_size[0] as f64, unzoomed.full_size[1] as f64], source_crop: placement.source_crop, @@ -3836,6 +3860,9 @@ impl ProjectUniforms { _padding1: [0.0; 3], border_color, corner_radii: display_corner_radii, + color_adjust_a: screen_color_grade.color_adjust_a, + color_adjust_b: screen_color_grade.color_adjust_b, + grain_params: screen_color_grade.grain_params, }, display_parent_motion_px, frame_chrome, @@ -4038,6 +4065,9 @@ impl ProjectUniforms { _padding1: [0.0; 3], border_color: [0.0, 0.0, 0.0, 0.0], corner_radii: [1.0; 4], + color_adjust_a: camera_color_grade.color_adjust_a, + color_adjust_b: camera_color_grade.color_adjust_b, + grain_params: camera_color_grade.grain_params, } }); @@ -4135,6 +4165,9 @@ impl ProjectUniforms { _padding1: [0.0; 3], border_color: [0.0, 0.0, 0.0, 0.0], corner_radii: [1.0; 4], + color_adjust_a: camera_color_grade.color_adjust_a, + color_adjust_b: camera_color_grade.color_adjust_b, + grain_params: camera_color_grade.grain_params, } }); @@ -4189,6 +4222,7 @@ impl ProjectUniforms { texts, camera3d, camera3d_zoom, + screen_color_grade, } } } @@ -5248,6 +5282,7 @@ impl<'a> FrameRenderer<'a> { pub struct RendererLayers { background: BackgroundLayer, background_blur: BlurLayer, + background_color_grade: ColorGradeLayer, frame: FrameLayer, display: DisplayLayer, notch: NotchLayer, @@ -5280,6 +5315,7 @@ impl RendererLayers { Self { background: BackgroundLayer::new(device), background_blur: BlurLayer::new(device), + background_color_grade: ColorGradeLayer::new(device), frame: FrameLayer::new(device, shared_composite_pipeline.clone()), notch: NotchLayer::new(device, shared_composite_pipeline.clone()), display: DisplayLayer::new_with_all_shared_pipelines( @@ -5444,6 +5480,9 @@ impl RendererLayers { self.background_blur.prepare(&constants.queue, uniforms); } + self.background_color_grade + .prepare(&constants.queue, uniforms); + if render_display { self.frame.prepare(constants, uniforms); self.notch @@ -5585,6 +5624,8 @@ impl RendererLayers { if uniforms.project.background.blur > 0.0 { self.background_blur.prepare(&constants.queue, uniforms); } + self.background_color_grade + .prepare(&constants.queue, uniforms); timings.background_blur_prepare_duration = start.elapsed(); let start = Instant::now(); @@ -5761,6 +5802,22 @@ impl RendererLayers { session.swap_textures(); } + // Grade the backdrop before any content layers draw, so the screen's + // color grade covers the whole scene (padding, wallpaper, blur) and + // not just the display card. Content layers apply their own grades. + // The pass overwrites every pixel (fullscreen triangle, no blending), + // so the target's old contents never need loading. + if self.background_color_grade.is_active() { + let mut pass = render_pass!( + session.other_texture_view(), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) + ); + self.background_color_grade + .render(&mut pass, device, session.current_texture_view()); + + session.swap_textures(); + } + let should_render_screen = render_display && uniforms.scene.should_render_screen() && self.display.has_valid_frame(); diff --git a/crates/rendering/src/shaders/color-grade.wgsl b/crates/rendering/src/shaders/color-grade.wgsl new file mode 100644 index 0000000000..5e95150d0d --- /dev/null +++ b/crates/rendering/src/shaders/color-grade.wgsl @@ -0,0 +1,105 @@ +// Full-frame color grade for the background canvas. Runs between the +// background (+ its blur) and the display layer so the backdrop wears the +// same grade as the screen and the padded scene reads as one frame. +// +// The grade math MUST stay in sync with apply_color_grade in +// composite-video-frame.wgsl: the display card applies the same grade in its +// own shader, and grain + vignette are computed in the same output-pixel +// space so the two layers meet seamlessly at the card edge. + +struct Uniforms { + // (exposure stops, contrast, saturation, temperature). + color_adjust_a: vec4, + // (tint, fade, split_tone, vignette). + color_adjust_b: vec4, + // (grain amount, per-frame grain seed, grade-active flag, unused). + grain_params: vec4, +}; + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var frame_texture: texture_2d; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + return vec4(positions[vertex_index], 0.0, 1.0); +} + +fn grain_hash(p: vec2) -> f32 { + var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +@fragment +fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { + let color = textureLoad(frame_texture, vec2(frag_coord.xy), 0); + + let exposure = uniforms.color_adjust_a.x; + let contrast = uniforms.color_adjust_a.y; + let saturation = uniforms.color_adjust_a.z; + let temperature = uniforms.color_adjust_a.w; + let tint = uniforms.color_adjust_b.x; + let fade = uniforms.color_adjust_b.y; + let split_tone = uniforms.color_adjust_b.z; + let vignette = uniforms.color_adjust_b.w; + let grain = uniforms.grain_params.x; + + // Exposure in stops. + var rgb = color.rgb * exp2(exposure); + + // White balance: temperature trades red against blue, tint trades green + // against magenta. Multiplicative so black stays black. + rgb = rgb * vec3( + 1.0 + 0.10 * temperature + 0.04 * tint, + 1.0 - 0.07 * tint, + 1.0 - 0.10 * temperature + 0.04 * tint, + ); + + // Contrast around mid gray. + rgb = (rgb - vec3(0.5)) * (1.0 + contrast) + vec3(0.5); + + // Saturation via luma mix (-1 = grayscale). + let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); + rgb = mix(vec3(luma), rgb, 1.0 + saturation); + + // Split toning: teal into shadows, orange into highlights (reversed when + // the amount is negative), weighted by luma bands. + let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); + let highlight_w = smoothstep(0.35, 0.8, luma); + rgb += split_tone * ( + shadow_w * vec3(-0.06, 0.02, 0.08) + + highlight_w * vec3(0.08, 0.02, -0.06) + ); + + // Film fade: raised blacks, gently dulled highlights. + rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); + + // Full-frame vignette (the display card computes its vignette in the + // same output space, so the field is continuous across the card edge). + if vignette > 0.0 { + let dims = vec2(textureDimensions(frame_texture)); + let r = length((frag_coord.xy / dims - vec2(0.5)) * 2.0); + rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); + } + + // Animated monochrome grain, peaked in the midtones like scanned film. + // Same hash, coordinates, and seed as the display layer so the noise + // field is continuous across the card boundary. + if grain > 0.0 { + let seed = uniforms.grain_params.y; + let noise = grain_hash(frag_coord.xy + vec2(seed * 17.0, seed * 29.0)); + let graded_luma = dot( + clamp(rgb, vec3(0.0), vec3(1.0)), + vec3(0.2126, 0.7152, 0.0722) + ); + let response = 0.25 + 0.75 * (1.0 - abs(2.0 * graded_luma - 1.0)); + rgb += (noise - 0.5) * grain * 0.35 * response; + } + + return vec4(clamp(rgb, vec3(0.0), vec3(1.0)), color.a); +} diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index b7b43776f4..f68d02c61e 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -26,6 +26,13 @@ struct Uniforms { // the uniform rounding; the display squares its top corners against // decorative frame chrome with (0, 0, 1, 1). corner_radii: vec4, + // Color grade: (exposure stops, contrast, saturation, temperature). + color_adjust_a: vec4, + // Color grade: (tint, fade, split_tone, vignette). + color_adjust_b: vec4, + // (grain amount, per-frame grain seed, grade-active flag, full-frame + // vignette flag). + grain_params: vec4, }; @group(0) @binding(0) var uniforms: Uniforms; @@ -131,6 +138,91 @@ fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32) return coverage * 0.25; } +// One-value hash with good distribution (same family as the background +// layer's gradient noise); sin-free so it stays stable across GPU drivers. +fn grain_hash(p: vec2) -> f32 { + var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// Applies the layer's color grade + film grain to a resolved video color. +// Runs once per output pixel (after any motion-blur resolve), so its cost is +// independent of blur tap counts, and ungraded layers skip everything in a +// single coherent uniform branch. +fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2) -> vec4 { + if uniforms.grain_params.z < 0.5 { + return color; + } + + let exposure = uniforms.color_adjust_a.x; + let contrast = uniforms.color_adjust_a.y; + let saturation = uniforms.color_adjust_a.z; + let temperature = uniforms.color_adjust_a.w; + let tint = uniforms.color_adjust_b.x; + let fade = uniforms.color_adjust_b.y; + let split_tone = uniforms.color_adjust_b.z; + let vignette = uniforms.color_adjust_b.w; + let grain = uniforms.grain_params.x; + + // Exposure in stops. + var rgb = color.rgb * exp2(exposure); + + // White balance: temperature trades red against blue, tint trades green + // against magenta. Multiplicative so black stays black. + rgb = rgb * vec3( + 1.0 + 0.10 * temperature + 0.04 * tint, + 1.0 - 0.07 * tint, + 1.0 - 0.10 * temperature + 0.04 * tint, + ); + + // Contrast around mid gray. + rgb = (rgb - vec3(0.5)) * (1.0 + contrast) + vec3(0.5); + + // Saturation via luma mix (-1 = grayscale). + let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); + rgb = mix(vec3(luma), rgb, 1.0 + saturation); + + // Split toning: teal into shadows, orange into highlights (reversed when + // the amount is negative), weighted by luma bands. + let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); + let highlight_w = smoothstep(0.35, 0.8, luma); + rgb += split_tone * ( + shadow_w * vec3(-0.06, 0.02, 0.08) + + highlight_w * vec3(0.08, 0.02, -0.06) + ); + + // Film fade: raised blacks, gently dulled highlights. + rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); + + // Vignette. The screen computes it over the full output frame so the + // card and the graded background share one continuous field; the camera + // vignettes within its own card. + if vignette > 0.0 { + var vig_uv = target_uv; + if uniforms.grain_params.w > 0.5 { + vig_uv = frag_pos / uniforms.output_size; + } + let r = length((vig_uv - vec2(0.5)) * 2.0); + rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); + } + + // Animated monochrome grain, peaked in the midtones like scanned film. + // The per-frame seed decorrelates the hash every frame. + if grain > 0.0 { + let seed = uniforms.grain_params.y; + let noise = grain_hash(frag_pos + vec2(seed * 17.0, seed * 29.0)); + let graded_luma = dot( + clamp(rgb, vec3(0.0), vec3(1.0)), + vec3(0.2126, 0.7152, 0.0722) + ); + let response = 0.25 + 0.75 * (1.0 - abs(2.0 * graded_luma - 1.0)); + rgb += (noise - 0.5) * grain * 0.35 * response; + } + + return vec4(clamp(rgb, vec3(0.0), vec3(1.0)), color.a); +} + fn composite_source_over(foreground: vec4, background: vec4) -> vec4 { let alpha = foreground.a + background.a * (1.0 - foreground.a); @@ -256,7 +348,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { let zoom_amount = uniforms.motion_blur_params.z; if !blur_active { - return composite_source_over(base_color, shadow_color); + return composite_source_over(apply_color_grade(base_color, target_uv, p), shadow_color); } // Screen Studio semantics: the user amount is baked into the LENGTH of @@ -270,7 +362,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { if blur_mode < 1.5 { let velocity_uv = uniforms.motion_blur_vector; if length(velocity_uv) < 1e-5 { - return composite_source_over(base_color, shadow_color); + return composite_source_over(apply_color_grade(base_color, target_uv, p), shadow_color); } // 21-tap box along [0, +v]: matches the reference directional filter @@ -291,14 +383,17 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { if out_alpha <= 0.0001 || alpha_sum <= 0.0001 { return shadow_color; } - return composite_source_over(vec4(accum / alpha_sum, out_alpha), shadow_color); + return composite_source_over( + apply_color_grade(vec4(accum / alpha_sum, out_alpha), target_uv, p), + shadow_color + ); } let zoom_center = uniforms.motion_blur_zoom_center; let dir = zoom_center - target_uv; let center_dist = length(dir); if center_dist < 1e-4 || zoom_amount < 1e-4 { - return composite_source_over(base_color, shadow_color); + return composite_source_over(apply_color_grade(base_color, target_uv, p), shadow_color); } // Radial blur toward the scale origin: ray length grows with distance @@ -332,7 +427,10 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { if out_alpha <= 0.0001 { return shadow_color; } - return composite_source_over(vec4(accum / alpha_sum, out_alpha), shadow_color); + return composite_source_over( + apply_color_grade(vec4(accum / alpha_sum, out_alpha), target_uv, p), + shadow_color + ); } fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { From 17cb54325ccc4d11d9d51109a37d0a3b56d89817 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:55 +0100 Subject: [PATCH 3/6] feat: grade the cursor with the screen color grade --- crates/rendering/src/layers/cursor.rs | 19 ++++- crates/rendering/src/shaders/cursor.wgsl | 95 +++++++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index f02b642054..4e4664f903 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -8,7 +8,7 @@ use wgpu::{BindGroup, FilterMode, include_wgsl, util::DeviceExt}; use crate::{ Coord, DecodedSegmentFrames, FrameSpace, ProjectUniforms, RenderVideoConstants, - STANDARD_CURSOR_HEIGHT, zoom::InterpolatedZoom, + STANDARD_CURSOR_HEIGHT, composite_frame::ColorGradeUniformParams, zoom::InterpolatedZoom, }; const CURSOR_CLICK_DURATION: f64 = 0.13; @@ -594,6 +594,15 @@ impl CursorLayer { ], }; + // The cursor wears the screen's grade so it reads as part of the + // graded footage; the same shared params keep its grain and vignette + // continuous with the card and backdrop underneath. + let cursor_grade = if uniforms.project.color_correction.grade_cursor { + uniforms.screen_color_grade + } else { + ColorGradeUniformParams::IDENTITY + }; + let cursor_uniforms = CursorUniforms { position_size, output_size: [ @@ -621,6 +630,9 @@ impl CursorLayer { uniforms.cursor_x_axis_tilt_radians, 0.0, ], + color_adjust_a: cursor_grade.color_adjust_a, + color_adjust_b: cursor_grade.color_adjust_b, + grain_params: cursor_grade.grain_params, }; constants.queue.write_buffer( @@ -714,6 +726,11 @@ pub struct CursorUniforms { screen_bounds: [f32; 4], motion_vector_strength: [f32; 4], rotation_params: [f32; 4], + /// Screen color grade (see `ColorGradeUniformParams`); identity when the + /// user opts the cursor out of the grade. + color_adjust_a: [f32; 4], + color_adjust_b: [f32; 4], + grain_params: [f32; 4], } fn compute_cursor_idle_opacity( diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index d8ad1142c7..6b0d959816 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -9,6 +9,17 @@ struct Uniforms { screen_bounds: vec4, motion_vector_strength: vec4, rotation_params: vec4, + // Screen color grade, identity when the cursor opts out. The math MUST + // stay in sync with apply_color_grade in composite-video-frame.wgsl and + // color-grade.wgsl: grain and vignette are computed in the same + // output-pixel space so the sprite blends seamlessly into the graded + // scene beneath it. + // (exposure stops, contrast, saturation, temperature). + color_adjust_a: vec4, + // (tint, fade, split_tone, vignette). + color_adjust_b: vec4, + // (grain amount, per-frame grain seed, grade-active flag, unused). + grain_params: vec4, }; @group(0) @binding(0) @@ -116,6 +127,86 @@ fn screen_bounds_mask(frag_pos: vec2) -> f32 { return clamp(inside + 0.5, 0.0, 1.0); } +fn grain_hash(p: vec2) -> f32 { + var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// Applies the screen's color grade to the resolved sprite color. This +// pipeline blends premultiplied (One, OneMinusSrcAlpha), so the color is +// lifted to straight alpha before grading and re-premultiplied after — +// otherwise the grade's additive terms (fade lift, split tone, grain) would +// bleed into the transparent parts of the motion smear. +fn apply_color_grade(color: vec4, frag_pos: vec2) -> vec4 { + if uniforms.grain_params.z < 0.5 || color.a < 0.001 { + return color; + } + + let exposure = uniforms.color_adjust_a.x; + let contrast = uniforms.color_adjust_a.y; + let saturation = uniforms.color_adjust_a.z; + let temperature = uniforms.color_adjust_a.w; + let tint = uniforms.color_adjust_b.x; + let fade = uniforms.color_adjust_b.y; + let split_tone = uniforms.color_adjust_b.z; + let vignette = uniforms.color_adjust_b.w; + let grain = uniforms.grain_params.x; + + // Exposure in stops. + var rgb = (color.rgb / color.a) * exp2(exposure); + + // White balance: temperature trades red against blue, tint trades green + // against magenta. Multiplicative so black stays black. + rgb = rgb * vec3( + 1.0 + 0.10 * temperature + 0.04 * tint, + 1.0 - 0.07 * tint, + 1.0 - 0.10 * temperature + 0.04 * tint, + ); + + // Contrast around mid gray. + rgb = (rgb - vec3(0.5)) * (1.0 + contrast) + vec3(0.5); + + // Saturation via luma mix (-1 = grayscale). + let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); + rgb = mix(vec3(luma), rgb, 1.0 + saturation); + + // Split toning: teal into shadows, orange into highlights (reversed when + // the amount is negative), weighted by luma bands. + let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); + let highlight_w = smoothstep(0.35, 0.8, luma); + rgb += split_tone * ( + shadow_w * vec3(-0.06, 0.02, 0.08) + + highlight_w * vec3(0.08, 0.02, -0.06) + ); + + // Film fade: raised blacks, gently dulled highlights. + rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); + + // The cursor lives in the full output frame, so its vignette always uses + // the frame-wide field the background and display card share. + if vignette > 0.0 { + let r = length((frag_pos / uniforms.output_size.xy - vec2(0.5)) * 2.0); + rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); + } + + // Animated monochrome grain, peaked in the midtones like scanned film. + // Same hash, coordinates, and seed as the layers underneath so the noise + // field stays continuous through the sprite's semi-transparent pixels. + if grain > 0.0 { + let seed = uniforms.grain_params.y; + let noise = grain_hash(frag_pos + vec2(seed * 17.0, seed * 29.0)); + let graded_luma = dot( + clamp(rgb, vec3(0.0), vec3(1.0)), + vec3(0.2126, 0.7152, 0.0722) + ); + let response = 0.25 + 0.75 * (1.0 - abs(2.0 * graded_luma - 1.0)); + rgb += (noise - 0.5) * grain * 0.35 * response; + } + + return vec4(clamp(rgb, vec3(0.0), vec3(1.0)) * color.a, color.a); +} + @fragment fn fs_main(input: VertexOutput) -> @location(0) vec4 { let velocity_uv = cursor_velocity_uv(); @@ -124,7 +215,7 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { let base_color = sample_cursor(input.uv); if (length(velocity_uv) < 0.005 || blur_strength < 0.001) { - return base_color * opacity; + return apply_color_grade(base_color * opacity, input.position.xy); } // 21-tap box along the motion vector, output fully blurred: the amount @@ -143,5 +234,5 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { } color /= kernel_size; - return color * opacity; + return apply_color_grade(color * opacity, input.position.xy); } From f842cf965ecf7ebb99c9ab3d153efd8d36b25135 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:55 +0100 Subject: [PATCH 4/6] feat: add color correction editor UI --- .../routes/editor/ColorCorrectionSection.tsx | 212 +++++++++++++++++ .../src/routes/editor/ConfigSidebar.tsx | 3 + .../src/routes/editor/colorCorrection.ts | 219 ++++++++++++++++++ apps/desktop/src/routes/editor/context.ts | 4 + apps/desktop/src/utils/tauri.ts | 72 +++++- 5 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/routes/editor/ColorCorrectionSection.tsx create mode 100644 apps/desktop/src/routes/editor/colorCorrection.ts diff --git a/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx new file mode 100644 index 0000000000..7b7dbee4b0 --- /dev/null +++ b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx @@ -0,0 +1,212 @@ +import { Collapsible as KCollapsible } from "@kobalte/core/collapsible"; +import { cx } from "cva"; +import { createSignal, For, Show } from "solid-js"; +import { produce } from "solid-js/store"; +import { Toggle } from "~/components/Toggle"; +import IconLucideGrip from "~icons/lucide/grip"; +import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; +import IconLucideSlidersHorizontal from "~icons/lucide/sliders-horizontal"; +import { + COLOR_CORRECTION_PRESETS, + COLOR_PRESET_CUSTOM, + COLOR_PREVIEW_GRAIN, + COLOR_PREVIEW_SCENE, + type ColorCorrectionTarget, + type ColorCorrectionValues, + type ColorPresetDefinition, +} from "./colorCorrection"; +import { useEditorContext } from "./context"; +import { Field, Slider } from "./ui"; + +const ADJUST_SLIDERS: { + key: keyof ColorCorrectionValues; + label: string; + min: number; + max: number; + /** Sliders that tune the selected look without demoting it to Custom. */ + keepsPreset?: boolean; +}[] = [ + { key: "intensity", label: "Strength", min: 0, max: 100, keepsPreset: true }, + { key: "exposure", label: "Exposure", min: -100, max: 100 }, + { key: "contrast", label: "Contrast", min: -100, max: 100 }, + { key: "saturation", label: "Saturation", min: -100, max: 100 }, + { key: "temperature", label: "Temperature", min: -100, max: 100 }, + { key: "tint", label: "Tint", min: -100, max: 100 }, + { key: "fade", label: "Fade", min: 0, max: 100 }, + { key: "splitTone", label: "Split Tone", min: -100, max: 100 }, + { key: "vignette", label: "Vignette", min: 0, max: 100 }, +]; + +function ColorPresetPreview(props: { preset: ColorPresetDefinition }) { + return ( +
+
+ +
+ + 0}> +
+ + 0}> +
+ +
+ ); +} + +export function ColorCorrectionSection(props: { + target: ColorCorrectionTarget; + scrollRef?: HTMLDivElement; +}) { + const { project, setProject } = useEditorContext(); + const [adjustOpen, setAdjustOpen] = createSignal(false); + + const grade = () => project.colorCorrection[props.target]; + + const applyPreset = (preset: ColorPresetDefinition) => { + setProject("colorCorrection", props.target, { + preset: preset.id, + ...preset.values, + }); + }; + + const setValue = ( + key: keyof ColorCorrectionValues, + value: number, + keepsPreset = false, + ) => { + setProject( + "colorCorrection", + props.target, + produce((current) => { + current[key] = value; + if (!keepsPreset) current.preset = COLOR_PRESET_CUSTOM; + }), + ); + }; + + const handleAdjustToggle = (open: boolean) => { + setAdjustOpen(open); + if (!open) return; + setTimeout(() => { + props.scrollRef?.scrollTo({ + top: props.scrollRef.scrollHeight, + behavior: "smooth", + }); + }, 200); + }; + + return ( + <> + } + > +
+ + {(preset) => ( + + )} + +
+
+ }> + setValue("grain", v[0] / 100, true)} + minValue={0} + maxValue={100} + step={1} + formatTooltip="%" + /> + + + } + value={ + + setProject("colorCorrection", "gradeCursor", gradeCursor) + } + /> + } + /> + +
+ + + Fine-tune colors + + + +
+ + {(slider) => ( + + + setValue( + slider.key, + v[0] / 100, + slider.keepsPreset ?? false, + ) + } + minValue={slider.min} + maxValue={slider.max} + step={1} + formatTooltip="%" + /> + + )} + +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 08cf05fc7d..6777487243 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -108,6 +108,7 @@ import { MIN_VOLUME_DB, } from "./audio"; import { BrandColorsDropdown } from "./BrandColorsDropdown"; +import { ColorCorrectionSection } from "./ColorCorrectionSection"; import { syncCaptionWordsWithText } from "./captions"; import { getColorPreviewBorderColor, hexToRgb, RgbInput } from "./color-utils"; import { type CornerRoundingType, useEditorContext } from "./context"; @@ -2982,6 +2983,7 @@ function BackgroundConfig(props: { }} /> + {/* }>
+ {/* }> ; + +export const COLOR_PRESET_NONE = "none"; +export const COLOR_PRESET_CUSTOM = "custom"; + +export const IDENTITY_COLOR_VALUES: ColorCorrectionValues = { + intensity: 1, + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + fade: 0, + splitTone: 0, + vignette: 0, + grain: 0, +}; + +export const DEFAULT_COLOR_CORRECTION: ColorCorrection = { + preset: COLOR_PRESET_NONE, + ...IDENTITY_COLOR_VALUES, +}; + +export function normalizeColorCorrection( + config: ColorCorrectionConfiguration | undefined | null, +): ColorCorrectionConfiguration { + return { + screen: { ...DEFAULT_COLOR_CORRECTION, ...config?.screen }, + camera: { ...DEFAULT_COLOR_CORRECTION, ...config?.camera }, + gradeCursor: config?.gradeCursor ?? true, + }; +} + +export type ColorPresetDefinition = { + id: string; + label: string; + description: string; + values: ColorCorrectionValues; + /** + * CSS approximation of the grade for the preset thumbnail. Vignette and + * grain overlays are derived from `values` instead so they always match. + */ + preview: { + filter?: string; + overlay?: string; + }; +}; + +/** + * The shared thumbnail "scene" every preset preview grades: sky, warm light, + * and deep shadow so contrast, temperature, and saturation shifts all read. + */ +export const COLOR_PREVIEW_SCENE = + "linear-gradient(160deg, #60a5fa 0%, #e2e8f0 35%, #fb923c 62%, #1e293b 100%)"; + +/** Tiny SVG turbulence tile used to preview film grain on preset cards. */ +export const COLOR_PREVIEW_GRAIN = `url("data:image/svg+xml;utf8,")`; + +export const COLOR_CORRECTION_PRESETS: ColorPresetDefinition[] = [ + { + id: COLOR_PRESET_NONE, + label: "None", + description: "Original colors, no grade applied", + values: { ...IDENTITY_COLOR_VALUES }, + preview: {}, + }, + { + id: "cinematic", + label: "Cinematic", + description: "Teal shadows and orange highlights with light grain", + values: { + ...IDENTITY_COLOR_VALUES, + contrast: 0.12, + saturation: 0.06, + temperature: 0.04, + splitTone: 0.45, + vignette: 0.18, + grain: 0.12, + }, + preview: { + filter: "contrast(1.12) saturate(1.12)", + overlay: + "linear-gradient(135deg, rgba(13, 148, 136, 0.45), rgba(251, 146, 60, 0.4))", + }, + }, + { + id: "noir", + label: "Noir", + description: "High-contrast black and white with heavy grain", + values: { + ...IDENTITY_COLOR_VALUES, + exposure: 0.04, + contrast: 0.3, + saturation: -1, + fade: 0.06, + vignette: 0.32, + grain: 0.35, + }, + preview: { + filter: "grayscale(1) contrast(1.35) brightness(1.03)", + }, + }, + { + id: "vintage", + label: "Vintage", + description: "Faded warm film look with soft contrast", + values: { + ...IDENTITY_COLOR_VALUES, + contrast: -0.06, + saturation: -0.18, + temperature: 0.22, + tint: 0.08, + fade: 0.28, + vignette: 0.14, + grain: 0.28, + }, + preview: { + filter: "sepia(0.5) contrast(0.92) saturate(0.8) brightness(1.05)", + }, + }, + { + id: "frost", + label: "Frost", + description: "Cool, crisp tones with muted color", + values: { + ...IDENTITY_COLOR_VALUES, + contrast: 0.08, + saturation: -0.08, + temperature: -0.3, + tint: -0.04, + fade: 0.06, + }, + preview: { + filter: "saturate(0.88) contrast(1.06) hue-rotate(-10deg)", + overlay: + "linear-gradient(180deg, rgba(125, 211, 252, 0.5), rgba(59, 130, 246, 0.3))", + }, + }, + { + id: "golden", + label: "Golden", + description: "Warm golden-hour glow", + values: { + ...IDENTITY_COLOR_VALUES, + exposure: 0.06, + contrast: 0.06, + saturation: 0.12, + temperature: 0.38, + fade: 0.04, + vignette: 0.1, + }, + preview: { + filter: "sepia(0.3) saturate(1.2) contrast(1.05) brightness(1.06)", + overlay: + "linear-gradient(180deg, rgba(253, 186, 116, 0.4), rgba(251, 113, 36, 0.25))", + }, + }, + { + id: "midnight", + label: "Midnight", + description: "Dark, moody teal with subdued color", + values: { + ...IDENTITY_COLOR_VALUES, + exposure: -0.08, + contrast: 0.16, + saturation: -0.22, + temperature: -0.1, + splitTone: 0.3, + vignette: 0.28, + grain: 0.18, + }, + preview: { + filter: "brightness(0.85) contrast(1.16) saturate(0.75)", + overlay: + "linear-gradient(180deg, rgba(15, 23, 42, 0.5), rgba(19, 78, 74, 0.45))", + }, + }, + { + id: "vivid", + label: "Vivid", + description: "Punchy saturation and contrast boost", + values: { + ...IDENTITY_COLOR_VALUES, + exposure: 0.02, + contrast: 0.14, + saturation: 0.32, + }, + preview: { + filter: "saturate(1.45) contrast(1.14)", + }, + }, + { + id: "dreamy", + label: "Dreamy", + description: "Soft, airy pastels with lifted blacks", + values: { + ...IDENTITY_COLOR_VALUES, + exposure: 0.08, + contrast: -0.14, + saturation: -0.04, + temperature: 0.06, + tint: 0.05, + fade: 0.3, + grain: 0.1, + }, + preview: { + filter: "brightness(1.1) contrast(0.85) saturate(0.95)", + overlay: + "linear-gradient(180deg, rgba(251, 207, 232, 0.45), rgba(196, 181, 253, 0.35))", + }, + }, +]; diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 7877d99ed6..b4aee239ba 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -38,6 +38,7 @@ import { } from "~/utils/socket"; import { type ClipSpeedAudioMode, + type ColorCorrectionConfiguration, commands, type EditorPreviewQuality, events, @@ -74,6 +75,7 @@ import { transitionsAfterClipDelete, transitionsAfterClipSplit, } from "./clip-transitions"; +import { normalizeColorCorrection } from "./colorCorrection"; import type { MaskSegment } from "./masks"; import type { SnapGuide } from "./snapping"; import type { TextSegment } from "./text"; @@ -225,6 +227,7 @@ export type EditorProjectConfiguration = Omit< timeline?: EditorTimelineConfiguration | null; captions: EditorCaptionsData | null; hiddenTextSegments?: number[]; + colorCorrection: ColorCorrectionConfiguration; }; function withCornerDefaults< @@ -306,6 +309,7 @@ export function normalizeProject( captions, background: withCornerDefaults(config.background), camera: withCornerDefaults(config.camera), + colorCorrection: normalizeColorCorrection(config.colorCorrection), }; } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 7ad31e0951..62f92eace7 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -689,8 +689,8 @@ export type Camera3DBlurMode = "none" | "radial" | "directional" | "tiltShift" * One scalar keyframe on a per-property track. Interpolation between two * keyframes is a linear value lerp with time remapped by a cubic bezier whose * P1 comes from the left keyframe's `out_easing` and P2 from the right one's - * `in_easing` (a split-handle model). Absent handles default to - * cubic ease-in-out: P1 [0.65, 0], P2 [0.35, 1]. + * `in_easing` (a split-handle model). Absent handles default to cubic + * ease-in-out: P1 [0.65, 0], P2 [0.35, 1]. */ export type Camera3DKeyframe = { /** @@ -732,7 +732,7 @@ roll?: number; /** * Content plane pitch. */ -rotateX?: number; +rotateX?: number; /** * Content plane yaw. */ @@ -809,6 +809,66 @@ export type ClipSpeedAudioMode = "mute" | "maintainPitch" | "matchSpeed" export type ClipTransition = { segmentIndex: number; type: ClipTransitionType; duration: number } export type ClipTransitionType = "cross-fade" | "fade-through-black" export type ClipboardSource = "raw" | "rendered" +/** + * Parametric color grade for a single layer (screen or camera). Every field + * except `intensity` has 0 as its identity, so a default struct renders + * exactly like no grade at all. Adjustment fields are normalized: -1..1 for + * bipolar controls, 0..1 for unipolar ones. + */ +export type ColorCorrection = { +/** + * UI preset id ("none", "cinematic", ..., or "custom"). The renderer + * ignores this; the numeric fields below are the source of truth. + */ +preset: string; +/** + * 0..1 master strength applied to every adjustment except `grain`, + * which has its own dedicated control. + */ +intensity: number; +/** + * -1..1, full scale is ±1.5 stops. + */ +exposure: number; +/** + * -1..1 around a mid-gray pivot. + */ +contrast: number; +/** + * -1..1; -1 is grayscale. + */ +saturation: number; +/** + * -1..1; positive warms, negative cools. + */ +temperature: number; +/** + * -1..1; positive shifts magenta, negative green. + */ +tint: number; +/** + * 0..1 lifted-blacks film fade. + */ +fade: number; +/** + * -1..1 teal-shadows/orange-highlights split toning (negative reverses). + */ +splitTone: number; +/** + * 0..1 edge darkening within the layer's own rect. + */ +vignette: number; +/** + * 0..1 animated film grain. + */ +grain: number } +export type ColorCorrectionConfiguration = { screen: ColorCorrection; camera: ColorCorrection; +/** + * Whether the screen grade also covers the rendered cursor. On by + * default so the pointer reads as part of the graded footage; off keeps + * it crisp for legibility over vignettes and grain. + */ +gradeCursor: boolean } export type CommercialLicense = { licenseKey: string; expiryDate: number | null; refresh: number; activatedOn: number } export type Condition = { type: "captureTargetIs"; target: CaptureTargetKind } | { type: "recordingModeIs"; mode: AutomationRecordingMode } | { type: "durationAtLeast"; secs: number } | { type: "durationAtMost"; secs: number } | { type: "windowTitleContains"; pattern: string } | { type: "organizationIs"; id: string } export type CornerStyle = "squircle" | "rounded" @@ -993,6 +1053,12 @@ export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline: TimelineConfiguration | null; captions: CaptionsData | null; keyboard: KeyboardData | null; clips: ClipConfiguration[]; annotations: Annotation[]; screenMotionBlur?: number; screenMovementSpring?: ScreenMovementSpring; +/** + * Per-layer cinematic color grades. Field-level default keeps old + * project files (and old saved presets) deserializing to the identity + * grade. + */ +colorCorrection?: ColorCorrectionConfiguration; /** * How text segment font sizes are interpreted. 0 (legacy): the renderer * multiplied `font_size` by `size.y / 0.2`, coupling glyph size to the From 8c40811b168a21c0b6c9e36e6f03dbbb106e66e1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:19:36 +0100 Subject: [PATCH 5/6] chore: trim narration comments in color correction editor UI --- .../src/routes/editor/ColorCorrectionSection.tsx | 1 - apps/desktop/src/routes/editor/colorCorrection.ts | 10 +--------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx index 7b7dbee4b0..f80918bdd6 100644 --- a/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx +++ b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx @@ -23,7 +23,6 @@ const ADJUST_SLIDERS: { label: string; min: number; max: number; - /** Sliders that tune the selected look without demoting it to Custom. */ keepsPreset?: boolean; }[] = [ { key: "intensity", label: "Strength", min: 0, max: 100, keepsPreset: true }, diff --git a/apps/desktop/src/routes/editor/colorCorrection.ts b/apps/desktop/src/routes/editor/colorCorrection.ts index 2ad1146e79..ff0ffa7b7d 100644 --- a/apps/desktop/src/routes/editor/colorCorrection.ts +++ b/apps/desktop/src/routes/editor/colorCorrection.ts @@ -43,24 +43,16 @@ export type ColorPresetDefinition = { label: string; description: string; values: ColorCorrectionValues; - /** - * CSS approximation of the grade for the preset thumbnail. Vignette and - * grain overlays are derived from `values` instead so they always match. - */ + /** CSS approximation for the thumbnail; vignette/grain derive from `values`. */ preview: { filter?: string; overlay?: string; }; }; -/** - * The shared thumbnail "scene" every preset preview grades: sky, warm light, - * and deep shadow so contrast, temperature, and saturation shifts all read. - */ export const COLOR_PREVIEW_SCENE = "linear-gradient(160deg, #60a5fa 0%, #e2e8f0 35%, #fb923c 62%, #1e293b 100%)"; -/** Tiny SVG turbulence tile used to preview film grain on preset cards. */ export const COLOR_PREVIEW_GRAIN = `url("data:image/svg+xml;utf8,")`; export const COLOR_CORRECTION_PRESETS: ColorPresetDefinition[] = [ From cdfd20a7f8a8ef1ed58c50328718a5f9b7d656e2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:37:42 +0100 Subject: [PATCH 6/6] chore: trim narration comments in color grade rendering --- crates/rendering/src/composite_frame.rs | 31 ++++++++----------- crates/rendering/src/layers/color_grade.rs | 12 +++---- crates/rendering/src/layers/cursor.rs | 7 ++--- crates/rendering/src/lib.rs | 16 ++++------ crates/rendering/src/shaders/color-grade.wgsl | 24 +++++--------- .../src/shaders/composite-video-frame.wgsl | 29 ++++++----------- crates/rendering/src/shaders/cursor.wgsl | 31 +++++++------------ 7 files changed, 53 insertions(+), 97 deletions(-) diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 559eef5033..c6510b784d 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -92,13 +92,13 @@ pub struct CompositeVideoFrameUniforms { /// squares its top corners against decorative frame chrome with /// `[0, 0, 1, 1]`. pub corner_radii: [f32; 4], - /// Color grade: (exposure in stops, contrast, saturation, temperature). + /// (exposure stops, contrast, saturation, temperature). pub color_adjust_a: [f32; 4], - /// Color grade: (tint, fade, split_tone, vignette). + /// (tint, fade, split_tone, vignette). pub color_adjust_b: [f32; 4], - /// (grain amount, per-frame grain seed, grade-active flag, full-frame - /// vignette flag). The active flag lets the shader skip the whole color - /// pass in one uniform branch when the layer has no grade. + /// (grain amount, grain seed, grade-active flag, full-frame vignette + /// flag). The active flag gates the shader's whole color pass in one + /// uniform branch, keeping ungraded layers bit-identical to before. pub grain_params: [f32; 4], } @@ -134,10 +134,9 @@ impl Default for CompositeVideoFrameUniforms { } } -/// The three uniform vec4s that drive the shader's color pass, derived from a -/// layer's [`cap_project::ColorCorrection`]. Intensity scaling happens here so -/// the shader never needs to know about it, and every field is clamped so a -/// hand-edited config can't push the shader outside its designed ranges. +/// Intensity scaling and range clamping happen here, Rust-side, so the +/// shader never sees unscaled values and a hand-edited config can't push it +/// outside its designed ranges. #[derive(Debug, Clone, Copy)] pub struct ColorGradeUniformParams { pub color_adjust_a: [f32; 4], @@ -152,17 +151,14 @@ impl ColorGradeUniformParams { grain_params: [0.0; 4], }; - /// Mirrors the shader's own gate: `grain_params.z` is the active flag - /// `from_config` sets when any adjustment survives clamping and intensity - /// scaling. + /// Must mirror the shader's own gate on `grain_params.z`. pub fn is_active(&self) -> bool { self.grain_params[2] > 0.5 } - /// `full_frame_vignette` selects the vignette's coordinate space: - /// the screen uses the full output frame (so the display card and the - /// graded background share one continuous vignette field), while the - /// camera vignettes within its own card. + /// `full_frame_vignette` selects the vignette's coordinate space: full + /// output frame for the screen (one continuous field across card and + /// backdrop), card-local for the camera. pub fn from_config( config: &cap_project::ColorCorrection, frame_number: u32, @@ -193,8 +189,7 @@ impl ColorGradeUniformParams { .iter() .any(|v| v.abs() > 1e-4); - // Cycling seed keeps grain animated while staying deterministic per - // frame number, so preview and export always match. + // Seed is deterministic per frame number so preview and export match. let grain_seed = (frame_number % 600) as f32; Self { diff --git a/crates/rendering/src/layers/color_grade.rs b/crates/rendering/src/layers/color_grade.rs index f23bb69b79..5ef1942f49 100644 --- a/crates/rendering/src/layers/color_grade.rs +++ b/crates/rendering/src/layers/color_grade.rs @@ -3,11 +3,8 @@ use wgpu::util::DeviceExt; use crate::ProjectUniforms; -/// Full-frame pass that applies the screen's color grade (including grain) -/// to the background canvas, so the backdrop and the display card read as one -/// graded scene. Runs between the background (+ its blur) and the display -/// layer, and only when the screen grade is active — an ungraded project -/// skips the pass entirely. +/// Full-frame pass applying the screen grade to the background canvas so the +/// backdrop and display card read as one graded scene. pub struct ColorGradeLayer { active: bool, uniforms_buffer: wgpu::Buffer, @@ -32,9 +29,8 @@ impl ColorGradeLayer { } pub fn prepare(&mut self, queue: &wgpu::Queue, uniforms: &ProjectUniforms) { - // Reuses the exact params the display card was given: grain and - // vignette only stay continuous across the card edge if both passes - // grade with identical values. + // Must reuse the display card's exact params: grain/vignette only + // stay continuous across the card edge with identical values. let params = uniforms.screen_color_grade; self.active = params.is_active(); if !self.active { diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 4e4664f903..db479d057c 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -594,9 +594,6 @@ impl CursorLayer { ], }; - // The cursor wears the screen's grade so it reads as part of the - // graded footage; the same shared params keep its grain and vignette - // continuous with the card and backdrop underneath. let cursor_grade = if uniforms.project.color_correction.grade_cursor { uniforms.screen_color_grade } else { @@ -726,8 +723,8 @@ pub struct CursorUniforms { screen_bounds: [f32; 4], motion_vector_strength: [f32; 4], rotation_params: [f32; 4], - /// Screen color grade (see `ColorGradeUniformParams`); identity when the - /// user opts the cursor out of the grade. + /// Screen grade (see `ColorGradeUniformParams`); identity when the + /// cursor opts out. color_adjust_a: [f32; 4], color_adjust_b: [f32; 4], grain_params: [f32; 4], diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index eb625d23ad..fe56996e6d 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -2251,9 +2251,8 @@ pub struct ProjectUniforms { /// The recording device's physical notch, redrawn over the capture; /// `None` when the overlay is off or the recording has no notch. pub notch: Option, - /// The screen grade's uniform params, shared verbatim by the display card - /// and the background grade pass so grain and vignette stay continuous - /// across the card edge. + /// Shared verbatim by the display card, background grade pass, and + /// cursor — grain/vignette continuity depends on identical params. screen_color_grade: ColorGradeUniformParams, /// Final placement of the outer display card (chrome included) in output /// px. Equals `display.target_bounds` when no frame is active. @@ -3800,8 +3799,7 @@ impl ProjectUniforms { border_color: [0.0; 4], frame_size: [1.0, 1.0], crop_bounds: [0.0, 0.0, 1.0, 1.0], - // The notch redraw is hardware, not video: never - // graded. + // Hardware redraw, not video: never graded. color_adjust_a: [0.0; 4], color_adjust_b: [0.0; 4], grain_params: [0.0; 4], @@ -5802,11 +5800,9 @@ impl RendererLayers { session.swap_textures(); } - // Grade the backdrop before any content layers draw, so the screen's - // color grade covers the whole scene (padding, wallpaper, blur) and - // not just the display card. Content layers apply their own grades. - // The pass overwrites every pixel (fullscreen triangle, no blending), - // so the target's old contents never need loading. + // Runs before content layers so the screen grade covers the whole + // backdrop; content layers grade themselves. The fullscreen triangle + // overwrites every pixel, so the old target contents never load. if self.background_color_grade.is_active() { let mut pass = render_pass!( session.other_texture_view(), diff --git a/crates/rendering/src/shaders/color-grade.wgsl b/crates/rendering/src/shaders/color-grade.wgsl index 5e95150d0d..d174c8ad7d 100644 --- a/crates/rendering/src/shaders/color-grade.wgsl +++ b/crates/rendering/src/shaders/color-grade.wgsl @@ -1,11 +1,7 @@ -// Full-frame color grade for the background canvas. Runs between the -// background (+ its blur) and the display layer so the backdrop wears the -// same grade as the screen and the padded scene reads as one frame. -// -// The grade math MUST stay in sync with apply_color_grade in -// composite-video-frame.wgsl: the display card applies the same grade in its -// own shader, and grain + vignette are computed in the same output-pixel -// space so the two layers meet seamlessly at the card edge. +// Full-frame grade for the background canvas. The math MUST stay in sync +// with apply_color_grade in composite-video-frame.wgsl and cursor.wgsl: +// grain and vignette share one output-pixel space so the layers meet +// seamlessly at the card edge. struct Uniforms { // (exposure stops, contrast, saturation, temperature). @@ -52,8 +48,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { // Exposure in stops. var rgb = color.rgb * exp2(exposure); - // White balance: temperature trades red against blue, tint trades green - // against magenta. Multiplicative so black stays black. + // White balance, multiplicative so black stays black. rgb = rgb * vec3( 1.0 + 0.10 * temperature + 0.04 * tint, 1.0 - 0.07 * tint, @@ -67,8 +62,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); rgb = mix(vec3(luma), rgb, 1.0 + saturation); - // Split toning: teal into shadows, orange into highlights (reversed when - // the amount is negative), weighted by luma bands. + // Split tone: teal shadows / orange highlights, luma-banded. let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); let highlight_w = smoothstep(0.35, 0.8, luma); rgb += split_tone * ( @@ -79,17 +73,13 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { // Film fade: raised blacks, gently dulled highlights. rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); - // Full-frame vignette (the display card computes its vignette in the - // same output space, so the field is continuous across the card edge). if vignette > 0.0 { let dims = vec2(textureDimensions(frame_texture)); let r = length((frag_coord.xy / dims - vec2(0.5)) * 2.0); rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); } - // Animated monochrome grain, peaked in the midtones like scanned film. - // Same hash, coordinates, and seed as the display layer so the noise - // field is continuous across the card boundary. + // Midtone-peaked monochrome grain. if grain > 0.0 { let seed = uniforms.grain_params.y; let noise = grain_hash(frag_coord.xy + vec2(seed * 17.0, seed * 29.0)); diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index f68d02c61e..f0adb5d6e0 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -26,12 +26,11 @@ struct Uniforms { // the uniform rounding; the display squares its top corners against // decorative frame chrome with (0, 0, 1, 1). corner_radii: vec4, - // Color grade: (exposure stops, contrast, saturation, temperature). + // (exposure stops, contrast, saturation, temperature). color_adjust_a: vec4, - // Color grade: (tint, fade, split_tone, vignette). + // (tint, fade, split_tone, vignette). color_adjust_b: vec4, - // (grain amount, per-frame grain seed, grade-active flag, full-frame - // vignette flag). + // (grain amount, grain seed, grade-active flag, full-frame vignette flag). grain_params: vec4, }; @@ -138,18 +137,16 @@ fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32) return coverage * 0.25; } -// One-value hash with good distribution (same family as the background -// layer's gradient noise); sin-free so it stays stable across GPU drivers. +// Sin-free hash so grain stays stable across GPU drivers. fn grain_hash(p: vec2) -> f32 { var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } -// Applies the layer's color grade + film grain to a resolved video color. -// Runs once per output pixel (after any motion-blur resolve), so its cost is -// independent of blur tap counts, and ungraded layers skip everything in a -// single coherent uniform branch. +// Must stay in sync with color-grade.wgsl and cursor.wgsl. Runs once per +// output pixel after motion-blur resolve; ungraded layers skip everything in +// a single coherent uniform branch. fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2) -> vec4 { if uniforms.grain_params.z < 0.5 { return color; @@ -168,8 +165,7 @@ fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2 // Exposure in stops. var rgb = color.rgb * exp2(exposure); - // White balance: temperature trades red against blue, tint trades green - // against magenta. Multiplicative so black stays black. + // White balance, multiplicative so black stays black. rgb = rgb * vec3( 1.0 + 0.10 * temperature + 0.04 * tint, 1.0 - 0.07 * tint, @@ -183,8 +179,7 @@ fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2 let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); rgb = mix(vec3(luma), rgb, 1.0 + saturation); - // Split toning: teal into shadows, orange into highlights (reversed when - // the amount is negative), weighted by luma bands. + // Split tone: teal shadows / orange highlights, luma-banded. let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); let highlight_w = smoothstep(0.35, 0.8, luma); rgb += split_tone * ( @@ -195,9 +190,6 @@ fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2 // Film fade: raised blacks, gently dulled highlights. rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); - // Vignette. The screen computes it over the full output frame so the - // card and the graded background share one continuous field; the camera - // vignettes within its own card. if vignette > 0.0 { var vig_uv = target_uv; if uniforms.grain_params.w > 0.5 { @@ -207,8 +199,7 @@ fn apply_color_grade(color: vec4, target_uv: vec2, frag_pos: vec2 rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); } - // Animated monochrome grain, peaked in the midtones like scanned film. - // The per-frame seed decorrelates the hash every frame. + // Midtone-peaked monochrome grain. if grain > 0.0 { let seed = uniforms.grain_params.y; let noise = grain_hash(frag_pos + vec2(seed * 17.0, seed * 29.0)); diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index 6b0d959816..00cd67d171 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -9,16 +9,12 @@ struct Uniforms { screen_bounds: vec4, motion_vector_strength: vec4, rotation_params: vec4, - // Screen color grade, identity when the cursor opts out. The math MUST - // stay in sync with apply_color_grade in composite-video-frame.wgsl and - // color-grade.wgsl: grain and vignette are computed in the same - // output-pixel space so the sprite blends seamlessly into the graded - // scene beneath it. + // Screen grade, identity when the cursor opts out. // (exposure stops, contrast, saturation, temperature). color_adjust_a: vec4, // (tint, fade, split_tone, vignette). color_adjust_b: vec4, - // (grain amount, per-frame grain seed, grade-active flag, unused). + // (grain amount, grain seed, grade-active flag, unused). grain_params: vec4, }; @@ -133,11 +129,11 @@ fn grain_hash(p: vec2) -> f32 { return fract((p3.x + p3.y) * p3.z); } -// Applies the screen's color grade to the resolved sprite color. This -// pipeline blends premultiplied (One, OneMinusSrcAlpha), so the color is -// lifted to straight alpha before grading and re-premultiplied after — -// otherwise the grade's additive terms (fade lift, split tone, grain) would -// bleed into the transparent parts of the motion smear. +// Must stay in sync with composite-video-frame.wgsl and color-grade.wgsl. +// This pipeline blends premultiplied (One, OneMinusSrcAlpha), so the color +// is lifted to straight alpha before grading and re-premultiplied after — +// otherwise the grade's additive terms (fade, split tone, grain) bleed into +// the transparent parts of the motion smear. fn apply_color_grade(color: vec4, frag_pos: vec2) -> vec4 { if uniforms.grain_params.z < 0.5 || color.a < 0.001 { return color; @@ -156,8 +152,7 @@ fn apply_color_grade(color: vec4, frag_pos: vec2) -> vec4 { // Exposure in stops. var rgb = (color.rgb / color.a) * exp2(exposure); - // White balance: temperature trades red against blue, tint trades green - // against magenta. Multiplicative so black stays black. + // White balance, multiplicative so black stays black. rgb = rgb * vec3( 1.0 + 0.10 * temperature + 0.04 * tint, 1.0 - 0.07 * tint, @@ -171,8 +166,7 @@ fn apply_color_grade(color: vec4, frag_pos: vec2) -> vec4 { let luma = dot(clamp(rgb, vec3(0.0), vec3(1.0)), vec3(0.2126, 0.7152, 0.0722)); rgb = mix(vec3(luma), rgb, 1.0 + saturation); - // Split toning: teal into shadows, orange into highlights (reversed when - // the amount is negative), weighted by luma bands. + // Split tone: teal shadows / orange highlights, luma-banded. let shadow_w = 1.0 - smoothstep(0.2, 0.65, luma); let highlight_w = smoothstep(0.35, 0.8, luma); rgb += split_tone * ( @@ -183,16 +177,13 @@ fn apply_color_grade(color: vec4, frag_pos: vec2) -> vec4 { // Film fade: raised blacks, gently dulled highlights. rgb = rgb * (1.0 - 0.18 * fade) + vec3(0.09 * fade); - // The cursor lives in the full output frame, so its vignette always uses - // the frame-wide field the background and display card share. + // Always the frame-wide vignette field, matching the layers underneath. if vignette > 0.0 { let r = length((frag_pos / uniforms.output_size.xy - vec2(0.5)) * 2.0); rgb = rgb * (1.0 - vignette * 0.65 * smoothstep(0.5, 1.5, r)); } - // Animated monochrome grain, peaked in the midtones like scanned film. - // Same hash, coordinates, and seed as the layers underneath so the noise - // field stays continuous through the sprite's semi-transparent pixels. + // Midtone-peaked monochrome grain. if grain > 0.0 { let seed = uniforms.grain_params.y; let noise = grain_hash(frag_pos + vec2(seed * 17.0, seed * 29.0));