tokimeki-image-editor 0.4.2 → 0.4.3

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.
@@ -120,6 +120,7 @@ $effect(() => {
120
120
  annotations;
121
121
  width;
122
122
  height;
123
+ theme;
123
124
  });
124
125
  // 2D Canvas: Render when parameters change
125
126
  $effect(() => {
@@ -1 +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 // Padding to align HSL block to 16-byte boundary\n // After cropHeight we're at offset 27*4=108, need to reach offset 128 (32*4) for vec4 alignment\n _padAlign0: f32,\n _padAlign1: f32,\n _padAlign2: f32,\n _padAlign3: f32,\n _padAlign4: f32,\n\n // HSL per-color adjustment (8 colors \u00D7 vec4 = 32 floats, starting at offset 128)\n // Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)\n hslRed: vec4<f32>,\n hslOrange: vec4<f32>,\n hslYellow: vec4<f32>,\n hslGreen: vec4<f32>,\n hslAqua: vec4<f32>,\n hslBlue: vec4<f32>,\n hslPurple: vec4<f32>,\n hslMagenta: vec4<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@group(0) @binding(3) var curveLUTSampler: sampler;\n@group(0) @binding(4) var curveLUTTexture: texture_2d<f32>;\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// \u2500\u2500 Lightroom-style HSL per-color adjustment \u2500\u2500\n//\n// 8 hue centers (degrees): Red=0, Orange=30, Yellow=60, Green=120,\n// Aqua=180, Blue=240, Purple=270, Magenta=300\n//\n// For any hue, finds the two adjacent centers and linearly interpolates\n// their adjustment values. Weights always sum to 1.0 \u2014 no dead zones,\n// no double-application, perfectly smooth transitions.\n//\nfn blendHSLAdjustments(hueDeg: f32,\n r: vec3<f32>, o: vec3<f32>, y: vec3<f32>, g: vec3<f32>,\n a: vec3<f32>, b: vec3<f32>, p: vec3<f32>, m: vec3<f32>\n) -> vec3<f32> {\n var h = hueDeg;\n // Normalize to [0, 360)\n h = h - floor(h / 360.0) * 360.0;\n\n // Piecewise linear interpolation between adjacent centers.\n // Each segment blends the two neighboring bands' adjustments.\n if (h < 30.0) {\n return mix(r, o, h / 30.0);\n } else if (h < 60.0) {\n return mix(o, y, (h - 30.0) / 30.0);\n } else if (h < 120.0) {\n return mix(y, g, (h - 60.0) / 60.0);\n } else if (h < 180.0) {\n return mix(g, a, (h - 120.0) / 60.0);\n } else if (h < 240.0) {\n return mix(a, b, (h - 180.0) / 60.0);\n } else if (h < 270.0) {\n return mix(b, p, (h - 240.0) / 30.0);\n } else if (h < 300.0) {\n return mix(p, m, (h - 270.0) / 30.0);\n } else {\n return mix(m, r, (h - 300.0) / 60.0);\n }\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\n // Sample curve LUT (must also be in uniform control flow)\n // We'll use it later \u2014 sample at a neutral point first to satisfy control flow rules\n let curveSampleR = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.r, 0.0, 1.0), 0.5));\n let curveSampleG = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.g, 0.0, 1.0), 0.5));\n let curveSampleB = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.b, 0.0, 1.0), 0.5));\n\n var rgb = color.rgb;\n\n // Determine pixel visibility (in-bounds and in-crop)\n var isVisible = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;\n\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.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {\n isVisible = false;\n }\n }\n\n // Apply tone curve for visible pixels; black for out-of-bounds/crop\n if (isVisible) {\n rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);\n } else {\n rgb = vec3<f32>(0.0);\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 // 4.5. HSL Per-Color Adjustment (Lightroom-style)\n // Blend adjustment values from the two adjacent hue bands, then apply once.\n {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n let hueDeg = hsl.x * 360.0;\n\n // Get blended adjustment: vec3(hueShift, satAdj, lumAdj)\n let adj = blendHSLAdjustments(hueDeg,\n params.hslRed.xyz, params.hslOrange.xyz, params.hslYellow.xyz, params.hslGreen.xyz,\n params.hslAqua.xyz, params.hslBlue.xyz, params.hslPurple.xyz, params.hslMagenta.xyz\n );\n\n // Apply only if there's any adjustment\n if (adj.x != 0.0 || adj.y != 0.0 || adj.z != 0.0) {\n // Hue shift (additive, wrapping)\n hsl.x = hsl.x + adj.x / 360.0;\n if (hsl.x < 0.0) { hsl.x = hsl.x + 1.0; }\n if (hsl.x > 1.0) { hsl.x = hsl.x - 1.0; }\n\n // Saturation (proportional \u2014 preserves relative saturation like Lightroom)\n hsl.y = clamp(hsl.y * (1.0 + adj.y / 100.0), 0.0, 1.0);\n\n // Luminance (weighted additive \u2014 stronger in midtones, gentler near extremes)\n let lumWeight = 4.0 * hsl.z * (1.0 - hsl.z); // peaks at L=0.5, zero at L=0 and L=1\n hsl.z = clamp(hsl.z + (adj.z / 100.0) * max(lumWeight, 0.15), 0.0, 1.0);\n\n rgb = hslToRgb(hsl);\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 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";
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 // Canvas clear color (3 params) + padding to align HSL vec4 block\n clearR: f32,\n clearG: f32,\n clearB: f32,\n _padAlign0: f32,\n _padAlign1: f32,\n\n // HSL per-color adjustment (8 colors \u00D7 vec4 = 32 floats, starting at offset 128)\n // Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)\n hslRed: vec4<f32>,\n hslOrange: vec4<f32>,\n hslYellow: vec4<f32>,\n hslGreen: vec4<f32>,\n hslAqua: vec4<f32>,\n hslBlue: vec4<f32>,\n hslPurple: vec4<f32>,\n hslMagenta: vec4<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@group(0) @binding(3) var curveLUTSampler: sampler;\n@group(0) @binding(4) var curveLUTTexture: texture_2d<f32>;\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// \u2500\u2500 Lightroom-style HSL per-color adjustment \u2500\u2500\n//\n// 8 hue centers (degrees): Red=0, Orange=30, Yellow=60, Green=120,\n// Aqua=180, Blue=240, Purple=270, Magenta=300\n//\n// For any hue, finds the two adjacent centers and linearly interpolates\n// their adjustment values. Weights always sum to 1.0 \u2014 no dead zones,\n// no double-application, perfectly smooth transitions.\n//\nfn blendHSLAdjustments(hueDeg: f32,\n r: vec3<f32>, o: vec3<f32>, y: vec3<f32>, g: vec3<f32>,\n a: vec3<f32>, b: vec3<f32>, p: vec3<f32>, m: vec3<f32>\n) -> vec3<f32> {\n var h = hueDeg;\n // Normalize to [0, 360)\n h = h - floor(h / 360.0) * 360.0;\n\n // Piecewise linear interpolation between adjacent centers.\n // Each segment blends the two neighboring bands' adjustments.\n if (h < 30.0) {\n return mix(r, o, h / 30.0);\n } else if (h < 60.0) {\n return mix(o, y, (h - 30.0) / 30.0);\n } else if (h < 120.0) {\n return mix(y, g, (h - 60.0) / 60.0);\n } else if (h < 180.0) {\n return mix(g, a, (h - 120.0) / 60.0);\n } else if (h < 240.0) {\n return mix(a, b, (h - 180.0) / 60.0);\n } else if (h < 270.0) {\n return mix(b, p, (h - 240.0) / 30.0);\n } else if (h < 300.0) {\n return mix(p, m, (h - 270.0) / 30.0);\n } else {\n return mix(m, r, (h - 300.0) / 60.0);\n }\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\n // Sample curve LUT (must also be in uniform control flow)\n // We'll use it later \u2014 sample at a neutral point first to satisfy control flow rules\n let curveSampleR = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.r, 0.0, 1.0), 0.5));\n let curveSampleG = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.g, 0.0, 1.0), 0.5));\n let curveSampleB = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.b, 0.0, 1.0), 0.5));\n\n var rgb = color.rgb;\n\n // Determine pixel visibility (in-bounds and in-crop)\n var isVisible = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;\n\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.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {\n isVisible = false;\n }\n }\n\n // Out-of-bounds: output the canvas clear color (theme-aware)\n if (!isVisible) {\n return vec4<f32>(params.clearR, params.clearG, params.clearB, 1.0);\n }\n\n // Apply tone curve for visible pixels\n rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);\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 // 4.5. HSL Per-Color Adjustment (Lightroom-style)\n // Blend adjustment values from the two adjacent hue bands, then apply once.\n {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n let hueDeg = hsl.x * 360.0;\n\n // Get blended adjustment: vec3(hueShift, satAdj, lumAdj)\n let adj = blendHSLAdjustments(hueDeg,\n params.hslRed.xyz, params.hslOrange.xyz, params.hslYellow.xyz, params.hslGreen.xyz,\n params.hslAqua.xyz, params.hslBlue.xyz, params.hslPurple.xyz, params.hslMagenta.xyz\n );\n\n // Apply only if there's any adjustment\n if (adj.x != 0.0 || adj.y != 0.0 || adj.z != 0.0) {\n // Hue shift (additive, wrapping)\n hsl.x = hsl.x + adj.x / 360.0;\n if (hsl.x < 0.0) { hsl.x = hsl.x + 1.0; }\n if (hsl.x > 1.0) { hsl.x = hsl.x - 1.0; }\n\n // Saturation (proportional \u2014 preserves relative saturation like Lightroom)\n hsl.y = clamp(hsl.y * (1.0 + adj.y / 100.0), 0.0, 1.0);\n\n // Luminance (weighted additive \u2014 stronger in midtones, gentler near extremes)\n let lumWeight = 4.0 * hsl.z * (1.0 - hsl.z); // peaks at L=0.5, zero at L=0 and L=1\n hsl.z = clamp(hsl.z + (adj.z / 100.0) * max(lumWeight, 0.15), 0.0, 1.0);\n\n rgb = hslToRgb(hsl);\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 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";
@@ -43,13 +43,12 @@ struct Uniforms {
43
43
  cropWidth: f32,
44
44
  cropHeight: f32,
45
45
 
46
- // Padding to align HSL block to 16-byte boundary
47
- // After cropHeight we're at offset 27*4=108, need to reach offset 128 (32*4) for vec4 alignment
46
+ // Canvas clear color (3 params) + padding to align HSL vec4 block
47
+ clearR: f32,
48
+ clearG: f32,
49
+ clearB: f32,
48
50
  _padAlign0: f32,
49
51
  _padAlign1: f32,
50
- _padAlign2: f32,
51
- _padAlign3: f32,
52
- _padAlign4: f32,
53
52
 
54
53
  // HSL per-color adjustment (8 colors × vec4 = 32 floats, starting at offset 128)
55
54
  // Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)
@@ -278,13 +277,14 @@ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
278
277
  }
279
278
  }
280
279
 
281
- // Apply tone curve for visible pixels; black for out-of-bounds/crop
282
- if (isVisible) {
283
- rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);
284
- } else {
285
- rgb = vec3<f32>(0.0);
280
+ // Out-of-bounds: output the canvas clear color (theme-aware)
281
+ if (!isVisible) {
282
+ return vec4<f32>(params.clearR, params.clearG, params.clearB, 1.0);
286
283
  }
287
284
 
285
+ // Apply tone curve for visible pixels
286
+ rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);
287
+
288
288
  // 1. Brightness
289
289
  if (params.brightness != 0.0) {
290
290
  let factor = 1.0 + (params.brightness / 200.0);
@@ -415,8 +415,8 @@ export function renderWithAdjustments(adjustments, viewport, transform, canvasWi
415
415
  cropArea?.y ?? 0,
416
416
  cropArea?.width ?? 0,
417
417
  cropArea?.height ?? 0,
418
- // Padding to align HSL vec4 block (5 floats: index 27-31)
419
- 0, 0, 0, 0, 0,
418
+ // Clear color (3 floats: index 27-29) + padding (2 floats: index 30-31)
419
+ canvasClearColor.r, canvasClearColor.g, canvasClearColor.b, 0, 0,
420
420
  // HSL per-color (8 × vec4: h, s, l, 0) = 32 floats (index 32-63)
421
421
  hsl.red.hue, hsl.red.saturation, hsl.red.luminance, 0,
422
422
  hsl.orange.hue, hsl.orange.saturation, hsl.orange.luminance, 0,
@@ -1155,7 +1155,7 @@ export async function exportWithWebGPU(imageSource, adjustments, transform, crop
1155
1155
  // Crop area (4 floats) — ends at index 26
1156
1156
  cropArea?.x ?? 0, cropArea?.y ?? 0,
1157
1157
  cropArea?.width ?? 0, cropArea?.height ?? 0,
1158
- // Padding to align HSL vec4 block (5 floats: index 27-31)
1158
+ // Clear color (3 floats) + padding (2 floats) export uses black background
1159
1159
  0, 0, 0, 0, 0,
1160
1160
  // HSL per-color (8 × vec4: h, s, l, 0) = 32 floats (index 32-63)
1161
1161
  hsl.red.hue, hsl.red.saturation, hsl.red.luminance, 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",