diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts index 523164d4..7cac8cc8 100644 --- a/apps/web/src/lib/effects/definitions/index.ts +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -2,11 +2,19 @@ import { effectsRegistry } from "../registry"; import { blurEffectDefinition } from "./blur"; import { brightnessContrastEffectDefinition } from "./brightness-contrast"; import { grayscaleEffectDefinition } from "./grayscale"; +import { invertEffectDefinition } from "./invert"; +import { saturationEffectDefinition } from "./saturation"; +import { sepiaEffectDefinition } from "./sepia"; +import { vignetteEffectDefinition } from "./vignette"; const defaultEffects = [ blurEffectDefinition, brightnessContrastEffectDefinition, grayscaleEffectDefinition, + saturationEffectDefinition, + sepiaEffectDefinition, + invertEffectDefinition, + vignetteEffectDefinition, ]; export function registerDefaultEffects(): void { diff --git a/apps/web/src/lib/effects/definitions/invert.ts b/apps/web/src/lib/effects/definitions/invert.ts new file mode 100644 index 00000000..e2db9744 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/invert.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "@/lib/effects/types"; + +export const INVERT_SHADER = "invert"; + +export const invertEffectDefinition: EffectDefinition = { + type: "invert", + name: "Invert", + keywords: ["invert", "negative", "reverse", "opposite"], + params: [ + { + key: "intensity", + label: "Intensity", + type: "number", + default: 100, + min: 0, + max: 100, + step: 1, + }, + ], + renderer: { + passes: [ + { + shader: INVERT_SHADER, + uniforms: ({ effectParams }) => { + const raw = effectParams.intensity; + const intensity = + typeof raw === "number" ? raw / 100 : 1; + return { u_intensity: intensity }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/saturation.ts b/apps/web/src/lib/effects/definitions/saturation.ts new file mode 100644 index 00000000..abfe2f05 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/saturation.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "@/lib/effects/types"; + +export const SATURATION_SHADER = "saturation"; + +export const saturationEffectDefinition: EffectDefinition = { + type: "saturation", + name: "Saturation", + keywords: ["saturation", "vibrance", "vivid", "color", "desaturate"], + params: [ + { + key: "saturation", + label: "Saturation", + type: "number", + default: 100, + min: 0, + max: 300, + step: 1, + }, + ], + renderer: { + passes: [ + { + shader: SATURATION_SHADER, + uniforms: ({ effectParams }) => { + const raw = effectParams.saturation; + const saturation = + typeof raw === "number" ? raw / 100 : 1; + return { u_saturation: saturation }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/sepia.ts b/apps/web/src/lib/effects/definitions/sepia.ts new file mode 100644 index 00000000..64f03249 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/sepia.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "@/lib/effects/types"; + +export const SEPIA_SHADER = "sepia"; + +export const sepiaEffectDefinition: EffectDefinition = { + type: "sepia", + name: "Sepia", + keywords: ["sepia", "vintage", "warm", "old", "retro", "antique"], + params: [ + { + key: "intensity", + label: "Intensity", + type: "number", + default: 100, + min: 0, + max: 100, + step: 1, + }, + ], + renderer: { + passes: [ + { + shader: SEPIA_SHADER, + uniforms: ({ effectParams }) => { + const raw = effectParams.intensity; + const intensity = + typeof raw === "number" ? raw / 100 : 1; + return { u_intensity: intensity }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/vignette.ts b/apps/web/src/lib/effects/definitions/vignette.ts new file mode 100644 index 00000000..a9d035ef --- /dev/null +++ b/apps/web/src/lib/effects/definitions/vignette.ts @@ -0,0 +1,48 @@ +import type { EffectDefinition } from "@/lib/effects/types"; + +export const VIGNETTE_SHADER = "vignette"; + +export const vignetteEffectDefinition: EffectDefinition = { + type: "vignette", + name: "Vignette", + keywords: ["vignette", "dark edges", "focus", "spotlight", "center"], + params: [ + { + key: "radius", + label: "Radius", + type: "number", + default: 75, + min: 10, + max: 100, + step: 1, + }, + { + key: "softness", + label: "Softness", + type: "number", + default: 35, + min: 5, + max: 80, + step: 1, + }, + ], + renderer: { + passes: [ + { + shader: VIGNETTE_SHADER, + uniforms: ({ effectParams }) => { + const rawRadius = effectParams.radius; + const rawSoftness = effectParams.softness; + const radius = + (typeof rawRadius === "number" ? rawRadius : 75) / + 100; + const softness = + (typeof rawSoftness === "number" + ? rawSoftness + : 35) / 100; + return { u_radius: radius, u_softness: softness }; + }, + }, + ], + }, +}; diff --git a/rust/crates/effects/src/pipeline.rs b/rust/crates/effects/src/pipeline.rs index fa73bff9..c189a28a 100644 --- a/rust/crates/effects/src/pipeline.rs +++ b/rust/crates/effects/src/pipeline.rs @@ -25,6 +25,26 @@ const SHADER_REGISTRY: &[(&str, &str, UniformPacker)] = &[ include_str!("shaders/grayscale.wgsl"), pack_grayscale as UniformPacker, ), + ( + "saturation", + include_str!("shaders/saturation.wgsl"), + pack_saturation as UniformPacker, + ), + ( + "sepia", + include_str!("shaders/sepia.wgsl"), + pack_sepia as UniformPacker, + ), + ( + "invert", + include_str!("shaders/invert.wgsl"), + pack_invert as UniformPacker, + ), + ( + "vignette", + include_str!("shaders/vignette.wgsl"), + pack_vignette as UniformPacker, + ), ]; pub struct ApplyEffectsOptions<'a> { @@ -406,3 +426,57 @@ fn pack_grayscale( buf.scalars[0][0] = intensity; Ok(buf) } + +fn pack_saturation( + pass: &EffectPass, + width: u32, + height: u32, +) -> Result { + let saturation = read_number_uniform_or(pass, "u_saturation", 1.0); + + let mut buf = GenericUniformBuffer::default(); + buf.resolution = [width as f32, height as f32]; + buf.scalars[0][0] = saturation; + Ok(buf) +} + +fn pack_sepia( + pass: &EffectPass, + width: u32, + height: u32, +) -> Result { + let intensity = read_number_uniform_or(pass, "u_intensity", 1.0); + + let mut buf = GenericUniformBuffer::default(); + buf.resolution = [width as f32, height as f32]; + buf.scalars[0][0] = intensity; + Ok(buf) +} + +fn pack_invert( + pass: &EffectPass, + width: u32, + height: u32, +) -> Result { + let intensity = read_number_uniform_or(pass, "u_intensity", 1.0); + + let mut buf = GenericUniformBuffer::default(); + buf.resolution = [width as f32, height as f32]; + buf.scalars[0][0] = intensity; + Ok(buf) +} + +fn pack_vignette( + pass: &EffectPass, + width: u32, + height: u32, +) -> Result { + let radius = read_number_uniform_or(pass, "u_radius", 0.75); + let softness = read_number_uniform_or(pass, "u_softness", 0.35); + + let mut buf = GenericUniformBuffer::default(); + buf.resolution = [width as f32, height as f32]; + buf.scalars[0][0] = radius; + buf.scalars[0][1] = softness; + Ok(buf) +} diff --git a/rust/crates/effects/src/shaders/invert.wgsl b/rust/crates/effects/src/shaders/invert.wgsl new file mode 100644 index 00000000..3c1acc5d --- /dev/null +++ b/rust/crates/effects/src/shaders/invert.wgsl @@ -0,0 +1,26 @@ +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) tex_coord: vec2f, +} + +struct EffectUniforms { + resolution: vec2f, + _pad_res: vec2f, + scalars: array, + vectors: array, +} + +@group(0) @binding(0) var input_texture: texture_2d; +@group(0) @binding(1) var input_sampler: sampler; +@group(1) @binding(0) var uniforms: EffectUniforms; + +@fragment +fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + let color = textureSample(input_texture, input_sampler, input.tex_coord); + let intensity = uniforms.scalars[0].x; + + let inverted = vec3f(1.0 - color.r, 1.0 - color.g, 1.0 - color.b); + let result = mix(color.rgb, inverted, intensity); + + return vec4f(result, color.a); +} diff --git a/rust/crates/effects/src/shaders/saturation.wgsl b/rust/crates/effects/src/shaders/saturation.wgsl new file mode 100644 index 00000000..a042da89 --- /dev/null +++ b/rust/crates/effects/src/shaders/saturation.wgsl @@ -0,0 +1,28 @@ +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) tex_coord: vec2f, +} + +struct EffectUniforms { + resolution: vec2f, + _pad_res: vec2f, + scalars: array, + vectors: array, +} + +@group(0) @binding(0) var input_texture: texture_2d; +@group(0) @binding(1) var input_sampler: sampler; +@group(1) @binding(0) var uniforms: EffectUniforms; + +@fragment +fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + let color = textureSample(input_texture, input_sampler, input.tex_coord); + let saturation = uniforms.scalars[0].x; + + let luminance = dot(color.rgb, vec3f(0.2126, 0.7152, 0.0722)); + let gray = vec3f(luminance); + + let result = mix(gray, color.rgb, saturation); + + return vec4f(result, color.a); +} diff --git a/rust/crates/effects/src/shaders/sepia.wgsl b/rust/crates/effects/src/shaders/sepia.wgsl new file mode 100644 index 00000000..1118e46b --- /dev/null +++ b/rust/crates/effects/src/shaders/sepia.wgsl @@ -0,0 +1,30 @@ +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) tex_coord: vec2f, +} + +struct EffectUniforms { + resolution: vec2f, + _pad_res: vec2f, + scalars: array, + vectors: array, +} + +@group(0) @binding(0) var input_texture: texture_2d; +@group(0) @binding(1) var input_sampler: sampler; +@group(1) @binding(0) var uniforms: EffectUniforms; + +@fragment +fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + let color = textureSample(input_texture, input_sampler, input.tex_coord); + let intensity = uniforms.scalars[0].x; + + let r = min(color.r * 0.393 + color.g * 0.769 + color.b * 0.189, 1.0); + let g = min(color.r * 0.349 + color.g * 0.686 + color.b * 0.168, 1.0); + let b = min(color.r * 0.272 + color.g * 0.534 + color.b * 0.131, 1.0); + + let sepia = vec3f(r, g, b); + let result = mix(color.rgb, sepia, intensity); + + return vec4f(result, color.a); +} diff --git a/rust/crates/effects/src/shaders/vignette.wgsl b/rust/crates/effects/src/shaders/vignette.wgsl new file mode 100644 index 00000000..5dad804d --- /dev/null +++ b/rust/crates/effects/src/shaders/vignette.wgsl @@ -0,0 +1,29 @@ +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) tex_coord: vec2f, +} + +struct EffectUniforms { + resolution: vec2f, + _pad_res: vec2f, + scalars: array, + vectors: array, +} + +@group(0) @binding(0) var input_texture: texture_2d; +@group(0) @binding(1) var input_sampler: sampler; +@group(1) @binding(0) var uniforms: EffectUniforms; + +@fragment +fn fragment_main(input: VertexOutput) -> @location(0) vec4f { + let color = textureSample(input_texture, input_sampler, input.tex_coord); + let radius = uniforms.scalars[0].x; + let softness = uniforms.scalars[0].y; + + let center = input.tex_coord - vec2f(0.5); + let dist = length(center); + + let edge = smoothstep(radius, radius - softness, dist); + + return vec4f(color.rgb * edge, color.a); +}