tokimeki-image-editor 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export declare const BLUR_SHADER_CODE = "struct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct BlurUniforms {\n direction: vec2<f32>, // (1, 0) for horizontal, (0, 1) for vertical\n radius: f32,\n padding: f32,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var myTexture: texture_2d<f32>;\n@group(0) @binding(2) var<uniform> blur: BlurUniforms;\n\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n // Convert NDC to UV with Y-flip (NDC bottom-left maps to UV top-left)\n let uv = pos[VertexIndex] * 0.5 + 0.5;\n output.uv = vec2<f32>(uv.x, 1.0 - uv.y);\n return output;\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n let r = i32(blur.radius);\n\n // Pass-through if radius is 0\n if (r == 0) {\n return textureSample(myTexture, mySampler, uv);\n }\n\n let texSize = vec2<f32>(textureDimensions(myTexture));\n let texelSize = 1.0 / texSize;\n\n // Gaussian blur weights (for radius up to 10)\n // Pre-normalized weights for different radii\n var color = vec4<f32>(0.0);\n\n // Simple box blur for now (equal weights)\n // TODO: Use proper Gaussian weights\n let totalSamples = f32(r * 2 + 1);\n\n // Sample along the blur direction\n for (var i = -r; i <= r; i = i + 1) {\n let offset = vec2<f32>(f32(i)) * blur.direction * texelSize;\n let sampleUV = uv + offset;\n color = color + textureSample(myTexture, mySampler, sampleUV) / totalSamples;\n }\n\n return color;\n}\n";
@@ -0,0 +1,60 @@
1
+ export const BLUR_SHADER_CODE = `struct VertexOutput {
2
+ @builtin(position) position: vec4<f32>,
3
+ @location(0) uv: vec2<f32>,
4
+ };
5
+
6
+ struct BlurUniforms {
7
+ direction: vec2<f32>, // (1, 0) for horizontal, (0, 1) for vertical
8
+ radius: f32,
9
+ padding: f32,
10
+ };
11
+
12
+ @group(0) @binding(0) var mySampler: sampler;
13
+ @group(0) @binding(1) var myTexture: texture_2d<f32>;
14
+ @group(0) @binding(2) var<uniform> blur: BlurUniforms;
15
+
16
+ @vertex
17
+ fn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
18
+ var pos = array<vec2<f32>, 3>(
19
+ vec2<f32>(-1.0, -1.0),
20
+ vec2<f32>( 3.0, -1.0),
21
+ vec2<f32>(-1.0, 3.0)
22
+ );
23
+ var output: VertexOutput;
24
+ output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
25
+ // Convert NDC to UV with Y-flip (NDC bottom-left maps to UV top-left)
26
+ let uv = pos[VertexIndex] * 0.5 + 0.5;
27
+ output.uv = vec2<f32>(uv.x, 1.0 - uv.y);
28
+ return output;
29
+ }
30
+
31
+ @fragment
32
+ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
33
+ let r = i32(blur.radius);
34
+
35
+ // Pass-through if radius is 0
36
+ if (r == 0) {
37
+ return textureSample(myTexture, mySampler, uv);
38
+ }
39
+
40
+ let texSize = vec2<f32>(textureDimensions(myTexture));
41
+ let texelSize = 1.0 / texSize;
42
+
43
+ // Gaussian blur weights (for radius up to 10)
44
+ // Pre-normalized weights for different radii
45
+ var color = vec4<f32>(0.0);
46
+
47
+ // Simple box blur for now (equal weights)
48
+ // TODO: Use proper Gaussian weights
49
+ let totalSamples = f32(r * 2 + 1);
50
+
51
+ // Sample along the blur direction
52
+ for (var i = -r; i <= r; i = i + 1) {
53
+ let offset = vec2<f32>(f32(i)) * blur.direction * texelSize;
54
+ let sampleUV = uv + offset;
55
+ color = color + textureSample(myTexture, mySampler, sampleUV) / totalSamples;
56
+ }
57
+
58
+ return color;
59
+ }
60
+ `;
@@ -0,0 +1 @@
1
+ export declare const COMPOSITE_SHADER_CODE = "struct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct CompositeUniforms {\n // Blur area bounds in normalized coordinates (0-1)\n minX: f32,\n minY: f32,\n maxX: f32,\n maxY: f32,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var blurredTexture: texture_2d<f32>;\n@group(0) @binding(2) var baseTexture: texture_2d<f32>;\n@group(0) @binding(3) var<uniform> composite: CompositeUniforms;\n\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n // Convert NDC to UV with Y-flip (NDC bottom-left maps to UV top-left)\n let uv = pos[VertexIndex] * 0.5 + 0.5;\n output.uv = vec2<f32>(uv.x, 1.0 - uv.y);\n return output;\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n // Sample both textures first (must be in uniform control flow)\n let blurredColor = textureSample(blurredTexture, mySampler, uv);\n let baseColor = textureSample(baseTexture, mySampler, uv);\n\n // Check if pixel is inside blur area bounds\n let inside = uv.x >= composite.minX && uv.x <= composite.maxX &&\n uv.y >= composite.minY && uv.y <= composite.maxY;\n\n // Use select to choose between blurred and base\n return select(baseColor, blurredColor, inside);\n}\n";
@@ -0,0 +1,47 @@
1
+ export const COMPOSITE_SHADER_CODE = `struct VertexOutput {
2
+ @builtin(position) position: vec4<f32>,
3
+ @location(0) uv: vec2<f32>,
4
+ };
5
+
6
+ struct CompositeUniforms {
7
+ // Blur area bounds in normalized coordinates (0-1)
8
+ minX: f32,
9
+ minY: f32,
10
+ maxX: f32,
11
+ maxY: f32,
12
+ };
13
+
14
+ @group(0) @binding(0) var mySampler: sampler;
15
+ @group(0) @binding(1) var blurredTexture: texture_2d<f32>;
16
+ @group(0) @binding(2) var baseTexture: texture_2d<f32>;
17
+ @group(0) @binding(3) var<uniform> composite: CompositeUniforms;
18
+
19
+ @vertex
20
+ fn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
21
+ var pos = array<vec2<f32>, 3>(
22
+ vec2<f32>(-1.0, -1.0),
23
+ vec2<f32>( 3.0, -1.0),
24
+ vec2<f32>(-1.0, 3.0)
25
+ );
26
+ var output: VertexOutput;
27
+ output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
28
+ // Convert NDC to UV with Y-flip (NDC bottom-left maps to UV top-left)
29
+ let uv = pos[VertexIndex] * 0.5 + 0.5;
30
+ output.uv = vec2<f32>(uv.x, 1.0 - uv.y);
31
+ return output;
32
+ }
33
+
34
+ @fragment
35
+ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
36
+ // Sample both textures first (must be in uniform control flow)
37
+ let blurredColor = textureSample(blurredTexture, mySampler, uv);
38
+ let baseColor = textureSample(baseTexture, mySampler, uv);
39
+
40
+ // Check if pixel is inside blur area bounds
41
+ let inside = uv.x >= composite.minX && uv.x <= composite.maxX &&
42
+ uv.y >= composite.minY && uv.y <= composite.maxY;
43
+
44
+ // Use select to choose between blurred and base
45
+ return select(baseColor, blurredColor, inside);
46
+ }
47
+ `;
@@ -0,0 +1 @@
1
+ export declare const GRAIN_SHADER_CODE = "// Grain shader - applies film grain on top of processed image\n// This shader is designed to be used as a final pass after blur\n\nstruct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct GrainUniforms {\n // Grain parameters (1 param)\n grainAmount: f32, // 0 to 100\n\n // Viewport (4 params)\n viewportZoom: f32,\n viewportOffsetX: f32,\n viewportOffsetY: f32,\n viewportScale: f32,\n\n // Transform (4 params)\n rotation: f32, // in radians\n flipHorizontal: f32, // 1.0 or -1.0\n flipVertical: f32, // 1.0 or -1.0\n transformScale: f32,\n\n // Canvas dimensions (2 params)\n canvasWidth: f32,\n canvasHeight: f32,\n\n // Image dimensions (2 params)\n imageWidth: f32,\n imageHeight: f32,\n\n // Crop area (4 params)\n cropX: f32,\n cropY: f32,\n cropWidth: f32,\n cropHeight: f32,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var myTexture: texture_2d<f32>;\n@group(0) @binding(2) var<uniform> params: GrainUniforms;\n\n// Simple pass-through vertex shader\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n // UV coordinates map directly to texture (0-1)\n output.uv = pos[VertexIndex] * 0.5 + 0.5;\n output.uv.y = 1.0 - output.uv.y; // Flip Y for texture coordinates\n return output;\n}\n\n// Helper functions\nfn getLuminance(color: vec3<f32>) -> f32 {\n return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));\n}\n\n// High-quality 2D hash function for uniform random numbers in [0, 1]\nfn hash2d(p: vec2<f32>) -> f32 {\n let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));\n let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);\n return fract((p3.x + p3.y) * p3.z + dot_p3);\n}\n\n// Generate two independent uniform random numbers\nfn hash2d_dual(p: vec2<f32>) -> vec2<f32> {\n let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));\n let p4 = fract(vec3<f32>(p.y, p.x, p.y) * vec3<f32>(0.0973, 0.1031, 0.1030));\n let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);\n let dot_p4 = dot(p4, vec3<f32>(p4.y, p4.z, p4.x) + 45.67);\n return vec2<f32>(\n fract((p3.x + p3.y) * p3.z + dot_p3),\n fract((p4.x + p4.y) * p4.z + dot_p4)\n );\n}\n\n// Box-Muller transform: converts two uniform random variables to Gaussian distribution\n// Returns a sample from N(0, 1) - standard normal distribution\nfn boxMuller(u1: f32, u2: f32) -> f32 {\n // Ensure u1 is not zero to avoid log(0)\n let u1_safe = max(u1, 0.0001);\n let radius = sqrt(-2.0 * log(u1_safe));\n let theta = 2.0 * 3.14159265359 * u2;\n return radius * cos(theta);\n}\n\n// Generate Gaussian-distributed noise using Box-Muller transform\nfn gaussianNoise(p: vec2<f32>) -> f32 {\n let uniforms = hash2d_dual(p);\n return boxMuller(uniforms.x, uniforms.y);\n}\n\n// Overlay blend mode - enhances contrast\n// When a < 0.5: result = 2 * a * b\n// When a >= 0.5: result = 1 - 2 * (1-a) * (1-b)\nfn overlayBlend(base: f32, blend: f32) -> f32 {\n if (base < 0.5) {\n return 2.0 * base * blend;\n } else {\n return 1.0 - 2.0 * (1.0 - base) * (1.0 - blend);\n }\n}\n\n// Soft light blend mode - gentler than overlay\nfn softLightBlend(base: f32, blend: f32) -> f32 {\n if (blend < 0.5) {\n return base - (1.0 - 2.0 * blend) * base * (1.0 - base);\n } else {\n let d = select(sqrt(base), base, base < 0.25);\n return base + (2.0 * blend - 1.0) * (d - base);\n }\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n // Sample the blurred/processed texture\n var color = textureSample(myTexture, mySampler, uv);\n var rgb = color.rgb;\n\n // Calculate image-space coordinates for grain\n // Convert UV (0-1) to canvas coordinates\n var canvasCoord = uv * vec2<f32>(params.canvasWidth, params.canvasHeight);\n\n // Convert canvas coordinates (top-left origin) to centered coordinates\n var coord = canvasCoord - vec2<f32>(params.canvasWidth * 0.5, params.canvasHeight * 0.5);\n\n // Reverse the viewport transformations to get image-space coordinates\n // 1. Subtract viewport offset\n coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);\n\n // 2. Inverse zoom/scale\n let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;\n coord = coord / totalScale;\n\n // 3. Inverse rotation\n if (params.rotation != 0.0) {\n let cos_r = cos(-params.rotation);\n let sin_r = sin(-params.rotation);\n coord = vec2<f32>(\n coord.x * cos_r - coord.y * sin_r,\n coord.x * sin_r + coord.y * cos_r\n );\n }\n\n // 4. Inverse flip\n coord.x = coord.x * params.flipHorizontal;\n coord.y = coord.y * params.flipVertical;\n\n // 5. Convert to image pixel coordinates\n var imageCoord: vec2<f32>;\n if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {\n // Crop mode: coord is in crop-centered space\n let cropLocalX = coord.x + params.cropWidth * 0.5;\n let cropLocalY = coord.y + params.cropHeight * 0.5;\n imageCoord = vec2<f32>(\n params.cropX + cropLocalX,\n params.cropY + cropLocalY\n );\n } else {\n // No crop: coord is in image-centered space\n imageCoord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);\n }\n\n // Apply film grain if enabled\n if (params.grainAmount > 0.0) {\n let grainIntensity = params.grainAmount / 100.0;\n\n // Calculate luminance for grain masking\n let luma = getLuminance(rgb);\n\n // === Authentic film grain using uniform distribution ===\n // Film grain is random, sharp, and granular - not smooth Gaussian\n\n // Primary grain layer (2.5-3 pixels) - sharp, visible grain\n let grain1 = hash2d(floor(imageCoord / 2.8)) - 0.5;\n\n // Secondary grain layer (3.5-4 pixels) - adds texture variation\n let grain2 = hash2d(floor(imageCoord / 3.6) + vec2<f32>(123.45, 678.90)) - 0.5;\n\n // Tertiary grain layer (5-6 pixels) - larger grain structure\n let grain3 = hash2d(floor(imageCoord / 5.2) + vec2<f32>(345.67, 890.12)) - 0.5;\n\n // Coarse grain layer (7-8 pixels) - clumping effect\n let grain4 = hash2d(floor(imageCoord / 7.5) + vec2<f32>(567.89, 234.56)) - 0.5;\n\n // Combine with emphasis on visible mid-size grain\n // Sharp transitions create authentic film texture\n let grainNoise = grain1 * 0.35 + grain2 * 0.3 + grain3 * 0.25 + grain4 * 0.1;\n\n // Enhance grain contrast for more pronounced effect\n let enhancedGrain = sign(grainNoise) * pow(abs(grainNoise), 0.8);\n\n // === Luminance-based masking ===\n // Grain visible across most tones, natural falloff at extremes\n var lumaMask = 1.0 - pow(abs(luma - 0.5) * 2.0, 1.5);\n lumaMask = smoothstep(0.1, 1.0, lumaMask);\n\n // Hide grain in very dark areas (outside image bounds)\n let shadowMask = smoothstep(0.02, 0.12, luma);\n\n // Slightly reduce grain in very bright areas\n let highlightMask = 1.0 - smoothstep(0.9, 1.0, luma);\n\n lumaMask = lumaMask * shadowMask * highlightMask;\n\n // === Apply strong, visible grain ===\n let grainStrength = grainIntensity * 0.35;\n\n // Apply grain with luminance masking\n let grainValue = enhancedGrain * grainStrength * lumaMask;\n rgb = rgb + vec3<f32>(grainValue);\n }\n\n // Clamp final result\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n\n return vec4<f32>(rgb, color.a);\n}\n";
@@ -0,0 +1,226 @@
1
+ export const GRAIN_SHADER_CODE = `// Grain shader - applies film grain on top of processed image
2
+ // This shader is designed to be used as a final pass after blur
3
+
4
+ struct VertexOutput {
5
+ @builtin(position) position: vec4<f32>,
6
+ @location(0) uv: vec2<f32>,
7
+ };
8
+
9
+ struct GrainUniforms {
10
+ // Grain parameters (1 param)
11
+ grainAmount: f32, // 0 to 100
12
+
13
+ // Viewport (4 params)
14
+ viewportZoom: f32,
15
+ viewportOffsetX: f32,
16
+ viewportOffsetY: f32,
17
+ viewportScale: f32,
18
+
19
+ // Transform (4 params)
20
+ rotation: f32, // in radians
21
+ flipHorizontal: f32, // 1.0 or -1.0
22
+ flipVertical: f32, // 1.0 or -1.0
23
+ transformScale: f32,
24
+
25
+ // Canvas dimensions (2 params)
26
+ canvasWidth: f32,
27
+ canvasHeight: f32,
28
+
29
+ // Image dimensions (2 params)
30
+ imageWidth: f32,
31
+ imageHeight: f32,
32
+
33
+ // Crop area (4 params)
34
+ cropX: f32,
35
+ cropY: f32,
36
+ cropWidth: f32,
37
+ cropHeight: f32,
38
+ };
39
+
40
+ @group(0) @binding(0) var mySampler: sampler;
41
+ @group(0) @binding(1) var myTexture: texture_2d<f32>;
42
+ @group(0) @binding(2) var<uniform> params: GrainUniforms;
43
+
44
+ // Simple pass-through vertex shader
45
+ @vertex
46
+ fn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
47
+ var pos = array<vec2<f32>, 3>(
48
+ vec2<f32>(-1.0, -1.0),
49
+ vec2<f32>( 3.0, -1.0),
50
+ vec2<f32>(-1.0, 3.0)
51
+ );
52
+ var output: VertexOutput;
53
+ output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
54
+ // UV coordinates map directly to texture (0-1)
55
+ output.uv = pos[VertexIndex] * 0.5 + 0.5;
56
+ output.uv.y = 1.0 - output.uv.y; // Flip Y for texture coordinates
57
+ return output;
58
+ }
59
+
60
+ // Helper functions
61
+ fn getLuminance(color: vec3<f32>) -> f32 {
62
+ return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
63
+ }
64
+
65
+ // High-quality 2D hash function for uniform random numbers in [0, 1]
66
+ fn hash2d(p: vec2<f32>) -> f32 {
67
+ let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));
68
+ let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);
69
+ return fract((p3.x + p3.y) * p3.z + dot_p3);
70
+ }
71
+
72
+ // Generate two independent uniform random numbers
73
+ fn hash2d_dual(p: vec2<f32>) -> vec2<f32> {
74
+ let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));
75
+ let p4 = fract(vec3<f32>(p.y, p.x, p.y) * vec3<f32>(0.0973, 0.1031, 0.1030));
76
+ let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);
77
+ let dot_p4 = dot(p4, vec3<f32>(p4.y, p4.z, p4.x) + 45.67);
78
+ return vec2<f32>(
79
+ fract((p3.x + p3.y) * p3.z + dot_p3),
80
+ fract((p4.x + p4.y) * p4.z + dot_p4)
81
+ );
82
+ }
83
+
84
+ // Box-Muller transform: converts two uniform random variables to Gaussian distribution
85
+ // Returns a sample from N(0, 1) - standard normal distribution
86
+ fn boxMuller(u1: f32, u2: f32) -> f32 {
87
+ // Ensure u1 is not zero to avoid log(0)
88
+ let u1_safe = max(u1, 0.0001);
89
+ let radius = sqrt(-2.0 * log(u1_safe));
90
+ let theta = 2.0 * 3.14159265359 * u2;
91
+ return radius * cos(theta);
92
+ }
93
+
94
+ // Generate Gaussian-distributed noise using Box-Muller transform
95
+ fn gaussianNoise(p: vec2<f32>) -> f32 {
96
+ let uniforms = hash2d_dual(p);
97
+ return boxMuller(uniforms.x, uniforms.y);
98
+ }
99
+
100
+ // Overlay blend mode - enhances contrast
101
+ // When a < 0.5: result = 2 * a * b
102
+ // When a >= 0.5: result = 1 - 2 * (1-a) * (1-b)
103
+ fn overlayBlend(base: f32, blend: f32) -> f32 {
104
+ if (base < 0.5) {
105
+ return 2.0 * base * blend;
106
+ } else {
107
+ return 1.0 - 2.0 * (1.0 - base) * (1.0 - blend);
108
+ }
109
+ }
110
+
111
+ // Soft light blend mode - gentler than overlay
112
+ fn softLightBlend(base: f32, blend: f32) -> f32 {
113
+ if (blend < 0.5) {
114
+ return base - (1.0 - 2.0 * blend) * base * (1.0 - base);
115
+ } else {
116
+ let d = select(sqrt(base), base, base < 0.25);
117
+ return base + (2.0 * blend - 1.0) * (d - base);
118
+ }
119
+ }
120
+
121
+ @fragment
122
+ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
123
+ // Sample the blurred/processed texture
124
+ var color = textureSample(myTexture, mySampler, uv);
125
+ var rgb = color.rgb;
126
+
127
+ // Calculate image-space coordinates for grain
128
+ // Convert UV (0-1) to canvas coordinates
129
+ var canvasCoord = uv * vec2<f32>(params.canvasWidth, params.canvasHeight);
130
+
131
+ // Convert canvas coordinates (top-left origin) to centered coordinates
132
+ var coord = canvasCoord - vec2<f32>(params.canvasWidth * 0.5, params.canvasHeight * 0.5);
133
+
134
+ // Reverse the viewport transformations to get image-space coordinates
135
+ // 1. Subtract viewport offset
136
+ coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);
137
+
138
+ // 2. Inverse zoom/scale
139
+ let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;
140
+ coord = coord / totalScale;
141
+
142
+ // 3. Inverse rotation
143
+ if (params.rotation != 0.0) {
144
+ let cos_r = cos(-params.rotation);
145
+ let sin_r = sin(-params.rotation);
146
+ coord = vec2<f32>(
147
+ coord.x * cos_r - coord.y * sin_r,
148
+ coord.x * sin_r + coord.y * cos_r
149
+ );
150
+ }
151
+
152
+ // 4. Inverse flip
153
+ coord.x = coord.x * params.flipHorizontal;
154
+ coord.y = coord.y * params.flipVertical;
155
+
156
+ // 5. Convert to image pixel coordinates
157
+ var imageCoord: vec2<f32>;
158
+ if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {
159
+ // Crop mode: coord is in crop-centered space
160
+ let cropLocalX = coord.x + params.cropWidth * 0.5;
161
+ let cropLocalY = coord.y + params.cropHeight * 0.5;
162
+ imageCoord = vec2<f32>(
163
+ params.cropX + cropLocalX,
164
+ params.cropY + cropLocalY
165
+ );
166
+ } else {
167
+ // No crop: coord is in image-centered space
168
+ imageCoord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);
169
+ }
170
+
171
+ // Apply film grain if enabled
172
+ if (params.grainAmount > 0.0) {
173
+ let grainIntensity = params.grainAmount / 100.0;
174
+
175
+ // Calculate luminance for grain masking
176
+ let luma = getLuminance(rgb);
177
+
178
+ // === Authentic film grain using uniform distribution ===
179
+ // Film grain is random, sharp, and granular - not smooth Gaussian
180
+
181
+ // Primary grain layer (2.5-3 pixels) - sharp, visible grain
182
+ let grain1 = hash2d(floor(imageCoord / 2.8)) - 0.5;
183
+
184
+ // Secondary grain layer (3.5-4 pixels) - adds texture variation
185
+ let grain2 = hash2d(floor(imageCoord / 3.6) + vec2<f32>(123.45, 678.90)) - 0.5;
186
+
187
+ // Tertiary grain layer (5-6 pixels) - larger grain structure
188
+ let grain3 = hash2d(floor(imageCoord / 5.2) + vec2<f32>(345.67, 890.12)) - 0.5;
189
+
190
+ // Coarse grain layer (7-8 pixels) - clumping effect
191
+ let grain4 = hash2d(floor(imageCoord / 7.5) + vec2<f32>(567.89, 234.56)) - 0.5;
192
+
193
+ // Combine with emphasis on visible mid-size grain
194
+ // Sharp transitions create authentic film texture
195
+ let grainNoise = grain1 * 0.35 + grain2 * 0.3 + grain3 * 0.25 + grain4 * 0.1;
196
+
197
+ // Enhance grain contrast for more pronounced effect
198
+ let enhancedGrain = sign(grainNoise) * pow(abs(grainNoise), 0.8);
199
+
200
+ // === Luminance-based masking ===
201
+ // Grain visible across most tones, natural falloff at extremes
202
+ var lumaMask = 1.0 - pow(abs(luma - 0.5) * 2.0, 1.5);
203
+ lumaMask = smoothstep(0.1, 1.0, lumaMask);
204
+
205
+ // Hide grain in very dark areas (outside image bounds)
206
+ let shadowMask = smoothstep(0.02, 0.12, luma);
207
+
208
+ // Slightly reduce grain in very bright areas
209
+ let highlightMask = 1.0 - smoothstep(0.9, 1.0, luma);
210
+
211
+ lumaMask = lumaMask * shadowMask * highlightMask;
212
+
213
+ // === Apply strong, visible grain ===
214
+ let grainStrength = grainIntensity * 0.35;
215
+
216
+ // Apply grain with luminance masking
217
+ let grainValue = enhancedGrain * grainStrength * lumaMask;
218
+ rgb = rgb + vec3<f32>(grainValue);
219
+ }
220
+
221
+ // Clamp final result
222
+ rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));
223
+
224
+ return vec4<f32>(rgb, color.a);
225
+ }
226
+ `;
@@ -0,0 +1 @@
1
+ export declare const IMAGE_EDITOR_SHADER_CODE = "struct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct Uniforms {\n // Adjustments (11 params)\n brightness: f32,\n contrast: f32,\n exposure: f32,\n highlights: f32,\n shadows: f32,\n saturation: f32,\n temperature: f32,\n sepia: f32,\n grayscale: f32,\n vignette: f32,\n grain: f32,\n\n // Viewport (4 params)\n viewportZoom: f32,\n viewportOffsetX: f32,\n viewportOffsetY: f32,\n viewportScale: f32,\n\n // Transform (4 params)\n rotation: f32, // in radians\n flipHorizontal: f32, // 1.0 or -1.0\n flipVertical: f32, // 1.0 or -1.0\n transformScale: f32,\n\n // Canvas dimensions (2 params)\n canvasWidth: f32,\n canvasHeight: f32,\n\n // Image dimensions (2 params)\n imageWidth: f32,\n imageHeight: f32,\n\n // Crop area (4 params)\n cropX: f32,\n cropY: f32,\n cropWidth: f32,\n cropHeight: f32,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var myTexture: texture_2d<f32>;\n@group(0) @binding(2) var<uniform> params: Uniforms;\n\n// Full-screen triangle vertex shader\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n\n // Convert NDC (-1 to 1) to canvas coordinates centered at origin\n // NDC: -1 \u2192 -canvasWidth/2, 0 \u2192 0, 1 \u2192 canvasWidth/2\n // Note: NDC Y increases upward, but canvas Y increases downward, so flip Y\n var coord = pos[VertexIndex] * vec2<f32>(params.canvasWidth, params.canvasHeight) * 0.5;\n coord.y = -coord.y;\n\n // Reverse the 2D canvas transformations:\n // In 2D: translate(center + offset) \u2192 scale(zoom) \u2192 rotate \u2192 flip \u2192 draw(-w/2, -h/2)\n // In WebGPU (reverse): screen \u2192 un-translate \u2192 un-scale \u2192 un-rotate \u2192 un-flip \u2192 texture\n\n // 1. Subtract viewport offset (inverse of translate)\n coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);\n\n // 2. Inverse zoom/scale\n let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;\n coord = coord / totalScale;\n\n // 3. Inverse rotation\n if (params.rotation != 0.0) {\n let cos_r = cos(-params.rotation);\n let sin_r = sin(-params.rotation);\n coord = vec2<f32>(\n coord.x * cos_r - coord.y * sin_r,\n coord.x * sin_r + coord.y * cos_r\n );\n }\n\n // 4. Inverse flip\n coord.x = coord.x * params.flipHorizontal;\n coord.y = coord.y * params.flipVertical;\n\n // 5. Convert to texture coordinates\n // After inverse transformations, coord is in drawing space (centered at origin)\n\n // When there's a crop, viewport.scale is adjusted to fit crop area to canvas\n // This means coord values are scaled according to crop dimensions\n // We need to account for this when mapping to texture coordinates\n\n // ALWAYS use crop-aware logic\n if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {\n // coord is in crop-centered space (units: pixels in crop area after inverse transforms)\n // The 2D canvas draws: drawImage(img, cropX, cropY, cropW, cropH, -cropW/2, -cropH/2, cropW, cropH)\n // This means the crop region is drawn centered, from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n\n // Map from drawing space to texture coordinates:\n // Drawing space: coord ranges from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n // Texture space: we want to read from (cropX, cropY) to (cropX+cropW, cropY+cropH)\n\n // Convert from centered coordinates to 0-based coordinates within crop region\n let cropLocalX = coord.x + params.cropWidth * 0.5;\n let cropLocalY = coord.y + params.cropHeight * 0.5;\n\n // Convert to texture coordinates by adding crop offset and normalizing by image size\n output.uv = vec2<f32>(\n (params.cropX + cropLocalX) / params.imageWidth,\n (params.cropY + cropLocalY) / params.imageHeight\n );\n } else {\n // No crop - standard transformation\n // Convert from image-centered space to top-left origin\n coord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);\n\n // Normalize to 0-1 range\n output.uv = coord / vec2<f32>(params.imageWidth, params.imageHeight);\n }\n\n return output;\n}\n\n// Helper functions\nfn getLuminance(color: vec3<f32>) -> f32 {\n return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));\n}\n\n// Improved 2D hash function for better randomness\nfn hash2d(p: vec2<f32>) -> f32 {\n let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));\n let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);\n return fract((p3.x + p3.y) * p3.z + dot_p3);\n}\n\nfn rgbToHsl(rgb: vec3<f32>) -> vec3<f32> {\n let maxVal = max(rgb.r, max(rgb.g, rgb.b));\n let minVal = min(rgb.r, min(rgb.g, rgb.b));\n var h = 0.0;\n var s = 0.0;\n let l = (maxVal + minVal) / 2.0;\n\n if (maxVal != minVal) {\n let d = maxVal - minVal;\n s = select(d / (maxVal + minVal), d / (2.0 - maxVal - minVal), l > 0.5);\n\n if (maxVal == rgb.r) {\n h = ((rgb.g - rgb.b) / d + select(0.0, 6.0, rgb.g < rgb.b)) / 6.0;\n } else if (maxVal == rgb.g) {\n h = ((rgb.b - rgb.r) / d + 2.0) / 6.0;\n } else {\n h = ((rgb.r - rgb.g) / d + 4.0) / 6.0;\n }\n }\n\n return vec3<f32>(h, s, l);\n}\n\nfn hslToRgb(hsl: vec3<f32>) -> vec3<f32> {\n let h = hsl.x;\n let s = hsl.y;\n let l = hsl.z;\n\n if (s == 0.0) {\n return vec3<f32>(l, l, l);\n }\n\n let q = select(l + s - l * s, l * (1.0 + s), l < 0.5);\n let p = 2.0 * l - q;\n\n let r = hue2rgb(p, q, h + 1.0 / 3.0);\n let g = hue2rgb(p, q, h);\n let b = hue2rgb(p, q, h - 1.0 / 3.0);\n\n return vec3<f32>(r, g, b);\n}\n\nfn hue2rgb(p: f32, q: f32, t_: f32) -> f32 {\n var t = t_;\n if (t < 0.0) { t += 1.0; }\n if (t > 1.0) { t -= 1.0; }\n if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }\n if (t < 1.0 / 2.0) { return q; }\n if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }\n return p;\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n // Sample texture FIRST (must be in uniform control flow before any branching)\n var color = textureSample(myTexture, mySampler, clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)));\n var rgb = color.rgb;\n\n // Check if outside texture bounds (0-1) and set to black\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n rgb = vec3<f32>(0.0);\n }\n\n // When crop is active, only show the crop region - black out everything else\n if (params.cropWidth > 0.0) {\n let cropMinU = params.cropX / params.imageWidth;\n let cropMaxU = (params.cropX + params.cropWidth) / params.imageWidth;\n let cropMinV = params.cropY / params.imageHeight;\n let cropMaxV = (params.cropY + params.cropHeight) / params.imageHeight;\n\n // If UV is outside the crop region, render black\n if (uv.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {\n rgb = vec3<f32>(0.0);\n }\n }\n\n // 1. Brightness\n if (params.brightness != 0.0) {\n let factor = 1.0 + (params.brightness / 200.0);\n rgb = rgb * factor;\n }\n\n // 2. Contrast\n if (params.contrast != 0.0) {\n let factor = 1.0 + (params.contrast / 200.0);\n rgb = (rgb - 0.5) * factor + 0.5;\n }\n\n // 3. Exposure\n if (params.exposure != 0.0) {\n rgb = rgb * exp2(params.exposure / 100.0);\n }\n\n // 4. Shadows and Highlights\n if (params.shadows != 0.0 || params.highlights != 0.0) {\n let luma = getLuminance(rgb);\n\n if (params.shadows != 0.0) {\n let shadowMask = pow(1.0 - luma, 2.0);\n rgb = rgb - rgb * (params.shadows / 100.0) * shadowMask * 0.5;\n }\n\n if (params.highlights != 0.0) {\n let highlightMask = pow(luma, 2.0);\n rgb = rgb + rgb * (params.highlights / 100.0) * highlightMask * 0.5;\n }\n }\n\n // 5. Saturation\n if (params.saturation != 0.0) {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n hsl.y = clamp(hsl.y * (1.0 + params.saturation / 100.0), 0.0, 1.0);\n rgb = hslToRgb(hsl);\n }\n\n // 5.5. Color Temperature\n // Warm (positive): add red, subtract blue\n // Cool (negative): subtract red, add blue\n if (params.temperature != 0.0) {\n let temp = params.temperature / 100.0;\n rgb.r = rgb.r + temp * 0.1;\n rgb.b = rgb.b - temp * 0.1;\n }\n\n // 6. Sepia\n if (params.sepia != 0.0) {\n let sepiaAmount = params.sepia / 100.0;\n let tr = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;\n let tg = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;\n let tb = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;\n rgb = mix(rgb, vec3<f32>(tr, tg, tb), sepiaAmount);\n }\n\n // 7. Grayscale\n if (params.grayscale != 0.0) {\n let gray = getLuminance(rgb);\n rgb = mix(rgb, vec3<f32>(gray), params.grayscale / 100.0);\n }\n\n // 8. Vignette\n if (params.vignette != 0.0) {\n let center = vec2<f32>(0.5, 0.5);\n let dist = distance(uv, center);\n let vignetteFactor = params.vignette / 100.0;\n let vignetteAmount = pow(dist * 1.4, 2.0);\n rgb = rgb * (1.0 + vignetteFactor * vignetteAmount * 1.5);\n }\n\n // Clamp final result\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n\n return vec4<f32>(rgb, color.a);\n}\n";
@@ -0,0 +1,297 @@
1
+ export const IMAGE_EDITOR_SHADER_CODE = `struct VertexOutput {
2
+ @builtin(position) position: vec4<f32>,
3
+ @location(0) uv: vec2<f32>,
4
+ };
5
+
6
+ struct Uniforms {
7
+ // Adjustments (11 params)
8
+ brightness: f32,
9
+ contrast: f32,
10
+ exposure: f32,
11
+ highlights: f32,
12
+ shadows: f32,
13
+ saturation: f32,
14
+ temperature: f32,
15
+ sepia: f32,
16
+ grayscale: f32,
17
+ vignette: f32,
18
+ grain: f32,
19
+
20
+ // Viewport (4 params)
21
+ viewportZoom: f32,
22
+ viewportOffsetX: f32,
23
+ viewportOffsetY: f32,
24
+ viewportScale: f32,
25
+
26
+ // Transform (4 params)
27
+ rotation: f32, // in radians
28
+ flipHorizontal: f32, // 1.0 or -1.0
29
+ flipVertical: f32, // 1.0 or -1.0
30
+ transformScale: f32,
31
+
32
+ // Canvas dimensions (2 params)
33
+ canvasWidth: f32,
34
+ canvasHeight: f32,
35
+
36
+ // Image dimensions (2 params)
37
+ imageWidth: f32,
38
+ imageHeight: f32,
39
+
40
+ // Crop area (4 params)
41
+ cropX: f32,
42
+ cropY: f32,
43
+ cropWidth: f32,
44
+ cropHeight: f32,
45
+ };
46
+
47
+ @group(0) @binding(0) var mySampler: sampler;
48
+ @group(0) @binding(1) var myTexture: texture_2d<f32>;
49
+ @group(0) @binding(2) var<uniform> params: Uniforms;
50
+
51
+ // Full-screen triangle vertex shader
52
+ @vertex
53
+ fn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
54
+ var pos = array<vec2<f32>, 3>(
55
+ vec2<f32>(-1.0, -1.0),
56
+ vec2<f32>( 3.0, -1.0),
57
+ vec2<f32>(-1.0, 3.0)
58
+ );
59
+ var output: VertexOutput;
60
+ output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
61
+
62
+ // Convert NDC (-1 to 1) to canvas coordinates centered at origin
63
+ // NDC: -1 → -canvasWidth/2, 0 → 0, 1 → canvasWidth/2
64
+ // Note: NDC Y increases upward, but canvas Y increases downward, so flip Y
65
+ var coord = pos[VertexIndex] * vec2<f32>(params.canvasWidth, params.canvasHeight) * 0.5;
66
+ coord.y = -coord.y;
67
+
68
+ // Reverse the 2D canvas transformations:
69
+ // In 2D: translate(center + offset) → scale(zoom) → rotate → flip → draw(-w/2, -h/2)
70
+ // In WebGPU (reverse): screen → un-translate → un-scale → un-rotate → un-flip → texture
71
+
72
+ // 1. Subtract viewport offset (inverse of translate)
73
+ coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);
74
+
75
+ // 2. Inverse zoom/scale
76
+ let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;
77
+ coord = coord / totalScale;
78
+
79
+ // 3. Inverse rotation
80
+ if (params.rotation != 0.0) {
81
+ let cos_r = cos(-params.rotation);
82
+ let sin_r = sin(-params.rotation);
83
+ coord = vec2<f32>(
84
+ coord.x * cos_r - coord.y * sin_r,
85
+ coord.x * sin_r + coord.y * cos_r
86
+ );
87
+ }
88
+
89
+ // 4. Inverse flip
90
+ coord.x = coord.x * params.flipHorizontal;
91
+ coord.y = coord.y * params.flipVertical;
92
+
93
+ // 5. Convert to texture coordinates
94
+ // After inverse transformations, coord is in drawing space (centered at origin)
95
+
96
+ // When there's a crop, viewport.scale is adjusted to fit crop area to canvas
97
+ // This means coord values are scaled according to crop dimensions
98
+ // We need to account for this when mapping to texture coordinates
99
+
100
+ // ALWAYS use crop-aware logic
101
+ if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {
102
+ // coord is in crop-centered space (units: pixels in crop area after inverse transforms)
103
+ // The 2D canvas draws: drawImage(img, cropX, cropY, cropW, cropH, -cropW/2, -cropH/2, cropW, cropH)
104
+ // This means the crop region is drawn centered, from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)
105
+
106
+ // Map from drawing space to texture coordinates:
107
+ // Drawing space: coord ranges from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)
108
+ // Texture space: we want to read from (cropX, cropY) to (cropX+cropW, cropY+cropH)
109
+
110
+ // Convert from centered coordinates to 0-based coordinates within crop region
111
+ let cropLocalX = coord.x + params.cropWidth * 0.5;
112
+ let cropLocalY = coord.y + params.cropHeight * 0.5;
113
+
114
+ // Convert to texture coordinates by adding crop offset and normalizing by image size
115
+ output.uv = vec2<f32>(
116
+ (params.cropX + cropLocalX) / params.imageWidth,
117
+ (params.cropY + cropLocalY) / params.imageHeight
118
+ );
119
+ } else {
120
+ // No crop - standard transformation
121
+ // Convert from image-centered space to top-left origin
122
+ coord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);
123
+
124
+ // Normalize to 0-1 range
125
+ output.uv = coord / vec2<f32>(params.imageWidth, params.imageHeight);
126
+ }
127
+
128
+ return output;
129
+ }
130
+
131
+ // Helper functions
132
+ fn getLuminance(color: vec3<f32>) -> f32 {
133
+ return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
134
+ }
135
+
136
+ // Improved 2D hash function for better randomness
137
+ fn hash2d(p: vec2<f32>) -> f32 {
138
+ let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));
139
+ let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);
140
+ return fract((p3.x + p3.y) * p3.z + dot_p3);
141
+ }
142
+
143
+ fn rgbToHsl(rgb: vec3<f32>) -> vec3<f32> {
144
+ let maxVal = max(rgb.r, max(rgb.g, rgb.b));
145
+ let minVal = min(rgb.r, min(rgb.g, rgb.b));
146
+ var h = 0.0;
147
+ var s = 0.0;
148
+ let l = (maxVal + minVal) / 2.0;
149
+
150
+ if (maxVal != minVal) {
151
+ let d = maxVal - minVal;
152
+ s = select(d / (maxVal + minVal), d / (2.0 - maxVal - minVal), l > 0.5);
153
+
154
+ if (maxVal == rgb.r) {
155
+ h = ((rgb.g - rgb.b) / d + select(0.0, 6.0, rgb.g < rgb.b)) / 6.0;
156
+ } else if (maxVal == rgb.g) {
157
+ h = ((rgb.b - rgb.r) / d + 2.0) / 6.0;
158
+ } else {
159
+ h = ((rgb.r - rgb.g) / d + 4.0) / 6.0;
160
+ }
161
+ }
162
+
163
+ return vec3<f32>(h, s, l);
164
+ }
165
+
166
+ fn hslToRgb(hsl: vec3<f32>) -> vec3<f32> {
167
+ let h = hsl.x;
168
+ let s = hsl.y;
169
+ let l = hsl.z;
170
+
171
+ if (s == 0.0) {
172
+ return vec3<f32>(l, l, l);
173
+ }
174
+
175
+ let q = select(l + s - l * s, l * (1.0 + s), l < 0.5);
176
+ let p = 2.0 * l - q;
177
+
178
+ let r = hue2rgb(p, q, h + 1.0 / 3.0);
179
+ let g = hue2rgb(p, q, h);
180
+ let b = hue2rgb(p, q, h - 1.0 / 3.0);
181
+
182
+ return vec3<f32>(r, g, b);
183
+ }
184
+
185
+ fn hue2rgb(p: f32, q: f32, t_: f32) -> f32 {
186
+ var t = t_;
187
+ if (t < 0.0) { t += 1.0; }
188
+ if (t > 1.0) { t -= 1.0; }
189
+ if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }
190
+ if (t < 1.0 / 2.0) { return q; }
191
+ if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
192
+ return p;
193
+ }
194
+
195
+ @fragment
196
+ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
197
+ // Sample texture FIRST (must be in uniform control flow before any branching)
198
+ var color = textureSample(myTexture, mySampler, clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)));
199
+ var rgb = color.rgb;
200
+
201
+ // Check if outside texture bounds (0-1) and set to black
202
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
203
+ rgb = vec3<f32>(0.0);
204
+ }
205
+
206
+ // When crop is active, only show the crop region - black out everything else
207
+ if (params.cropWidth > 0.0) {
208
+ let cropMinU = params.cropX / params.imageWidth;
209
+ let cropMaxU = (params.cropX + params.cropWidth) / params.imageWidth;
210
+ let cropMinV = params.cropY / params.imageHeight;
211
+ let cropMaxV = (params.cropY + params.cropHeight) / params.imageHeight;
212
+
213
+ // If UV is outside the crop region, render black
214
+ if (uv.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {
215
+ rgb = vec3<f32>(0.0);
216
+ }
217
+ }
218
+
219
+ // 1. Brightness
220
+ if (params.brightness != 0.0) {
221
+ let factor = 1.0 + (params.brightness / 200.0);
222
+ rgb = rgb * factor;
223
+ }
224
+
225
+ // 2. Contrast
226
+ if (params.contrast != 0.0) {
227
+ let factor = 1.0 + (params.contrast / 200.0);
228
+ rgb = (rgb - 0.5) * factor + 0.5;
229
+ }
230
+
231
+ // 3. Exposure
232
+ if (params.exposure != 0.0) {
233
+ rgb = rgb * exp2(params.exposure / 100.0);
234
+ }
235
+
236
+ // 4. Shadows and Highlights
237
+ if (params.shadows != 0.0 || params.highlights != 0.0) {
238
+ let luma = getLuminance(rgb);
239
+
240
+ if (params.shadows != 0.0) {
241
+ let shadowMask = pow(1.0 - luma, 2.0);
242
+ rgb = rgb - rgb * (params.shadows / 100.0) * shadowMask * 0.5;
243
+ }
244
+
245
+ if (params.highlights != 0.0) {
246
+ let highlightMask = pow(luma, 2.0);
247
+ rgb = rgb + rgb * (params.highlights / 100.0) * highlightMask * 0.5;
248
+ }
249
+ }
250
+
251
+ // 5. Saturation
252
+ if (params.saturation != 0.0) {
253
+ rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));
254
+ var hsl = rgbToHsl(rgb);
255
+ hsl.y = clamp(hsl.y * (1.0 + params.saturation / 100.0), 0.0, 1.0);
256
+ rgb = hslToRgb(hsl);
257
+ }
258
+
259
+ // 5.5. Color Temperature
260
+ // Warm (positive): add red, subtract blue
261
+ // Cool (negative): subtract red, add blue
262
+ if (params.temperature != 0.0) {
263
+ let temp = params.temperature / 100.0;
264
+ rgb.r = rgb.r + temp * 0.1;
265
+ rgb.b = rgb.b - temp * 0.1;
266
+ }
267
+
268
+ // 6. Sepia
269
+ if (params.sepia != 0.0) {
270
+ let sepiaAmount = params.sepia / 100.0;
271
+ let tr = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;
272
+ let tg = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;
273
+ let tb = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;
274
+ rgb = mix(rgb, vec3<f32>(tr, tg, tb), sepiaAmount);
275
+ }
276
+
277
+ // 7. Grayscale
278
+ if (params.grayscale != 0.0) {
279
+ let gray = getLuminance(rgb);
280
+ rgb = mix(rgb, vec3<f32>(gray), params.grayscale / 100.0);
281
+ }
282
+
283
+ // 8. Vignette
284
+ if (params.vignette != 0.0) {
285
+ let center = vec2<f32>(0.5, 0.5);
286
+ let dist = distance(uv, center);
287
+ let vignetteFactor = params.vignette / 100.0;
288
+ let vignetteAmount = pow(dist * 1.4, 2.0);
289
+ rgb = rgb * (1.0 + vignetteFactor * vignetteAmount * 1.5);
290
+ }
291
+
292
+ // Clamp final result
293
+ rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));
294
+
295
+ return vec4<f32>(rgb, color.a);
296
+ }
297
+ `;
@@ -1,7 +1,8 @@
1
- import SHADER_CODE from '../shaders/image-editor.wgsl?raw';
2
- import BLUR_SHADER_CODE from '../shaders/blur.wgsl?raw';
3
- import COMPOSITE_SHADER_CODE from '../shaders/composite.wgsl?raw';
4
- import GRAIN_SHADER_CODE from '../shaders/grain.wgsl?raw';
1
+ import { IMAGE_EDITOR_SHADER_CODE } from '../shaders/image-editor';
2
+ import { BLUR_SHADER_CODE } from '../shaders/blur';
3
+ import { COMPOSITE_SHADER_CODE } from '../shaders/composite';
4
+ import { GRAIN_SHADER_CODE } from '../shaders/grain';
5
+ const SHADER_CODE = IMAGE_EDITOR_SHADER_CODE;
5
6
  /**
6
7
  * WebGPU Render Pipeline for image adjustments with viewport and transform support
7
8
  * Uses fragment shader to apply adjustments in real-time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",