diff --git a/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx
new file mode 100644
index 0000000000..f80918bdd6
--- /dev/null
+++ b/apps/desktop/src/routes/editor/ColorCorrectionSection.tsx
@@ -0,0 +1,211 @@
+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;
+ 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 for the thumbnail; vignette/grain derive from `values`. */
+ preview: {
+ filter?: string;
+ overlay?: string;
+ };
+};
+
+export const COLOR_PREVIEW_SCENE =
+ "linear-gradient(160deg, #60a5fa 0%, #e2e8f0 35%, #fb923c 62%, #1e293b 100%)";
+
+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
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,
}
}
diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs
index 8a56e705f8..c6510b784d 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],
+ /// (exposure stops, contrast, saturation, temperature).
+ pub color_adjust_a: [f32; 4],
+ /// (tint, fade, split_tone, vignette).
+ pub color_adjust_b: [f32; 4],
+ /// (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],
}
impl Default for CompositeVideoFrameUniforms {
@@ -119,6 +127,80 @@ 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],
+ }
+ }
+}
+
+/// 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],
+ 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],
+ };
+
+ /// 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: 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,
+ 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);
+
+ // Seed is deterministic per frame number so preview and export 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..5ef1942f49
--- /dev/null
+++ b/crates/rendering/src/layers/color_grade.rs
@@ -0,0 +1,184 @@
+use bytemuck::{Pod, Zeroable};
+use wgpu::util::DeviceExt;
+
+use crate::ProjectUniforms;
+
+/// 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,
+ 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) {
+ // 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 {
+ 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/cursor.rs b/crates/rendering/src/layers/cursor.rs
index f02b642054..db479d057c 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,12 @@ impl CursorLayer {
],
};
+ 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 +627,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 +723,11 @@ pub struct CursorUniforms {
screen_bounds: [f32; 4],
motion_vector_strength: [f32; 4],
rotation_params: [f32; 4],
+ /// Screen grade (see `ColorGradeUniformParams`); identity when the
+ /// cursor opts out.
+ 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/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..fe56996e6d 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,9 @@ 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,
+ /// 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.
display_outer_bounds: [f32; 4],
@@ -3286,6 +3289,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 +3733,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 +3799,10 @@ impl ProjectUniforms {
border_color: [0.0; 4],
frame_size: [1.0, 1.0],
crop_bounds: [0.0, 0.0, 1.0, 1.0],
+ // Hardware redraw, 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 +3858,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 +4063,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 +4163,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 +4220,7 @@ impl ProjectUniforms {
texts,
camera3d,
camera3d_zoom,
+ screen_color_grade,
}
}
}
@@ -5248,6 +5280,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 +5313,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 +5478,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 +5622,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 +5800,20 @@ impl RendererLayers {
session.swap_textures();
}
+ // 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(),
+ 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..d174c8ad7d
--- /dev/null
+++ b/crates/rendering/src/shaders/color-grade.wgsl
@@ -0,0 +1,95 @@
+// 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).
+ 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, 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 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 * (
+ 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);
+
+ 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));
+ }
+
+ // 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));
+ 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..f0adb5d6e0 100644
--- a/crates/rendering/src/shaders/composite-video-frame.wgsl
+++ b/crates/rendering/src/shaders/composite-video-frame.wgsl
@@ -26,6 +26,12 @@ struct Uniforms {
// the uniform rounding; the display squares its top corners against
// decorative frame chrome with (0, 0, 1, 1).
corner_radii: vec4,
+ // (exposure stops, contrast, saturation, temperature).
+ color_adjust_a: vec4,
+ // (tint, fade, split_tone, vignette).
+ color_adjust_b: vec4,
+ // (grain amount, grain seed, grade-active flag, full-frame vignette flag).
+ grain_params: vec4,
};
@group(0) @binding(0) var uniforms: Uniforms;
@@ -131,6 +137,83 @@ fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32)
return coverage * 0.25;
}
+// 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);
+}
+
+// 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;
+ }
+
+ 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, 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 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 * (
+ 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);
+
+ 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));
+ }
+
+ // 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));
+ 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 +339,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 +353,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 +374,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 +418,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 {
diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl
index d8ad1142c7..00cd67d171 100644
--- a/crates/rendering/src/shaders/cursor.wgsl
+++ b/crates/rendering/src/shaders/cursor.wgsl
@@ -9,6 +9,13 @@ struct Uniforms {
screen_bounds: vec4,
motion_vector_strength: vec4,
rotation_params: vec4,
+ // 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, grain seed, grade-active flag, unused).
+ grain_params: vec4,
};
@group(0) @binding(0)
@@ -116,6 +123,81 @@ 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);
+}
+
+// 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;
+ }
+
+ 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, 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 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 * (
+ 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);
+
+ // 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));
+ }
+
+ // 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));
+ 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 +206,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 +225,5 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 {
}
color /= kernel_size;
- return color * opacity;
+ return apply_color_grade(color * opacity, input.position.xy);
}