Spaces:
Running
Running
| export const SHARE_HALFTONE_DOT_RULE = { | |
| step: 5, | |
| minRadius: 0.1, | |
| maxRadiusRatio: 0.46, | |
| paperThreshold: 0, | |
| sizeCurve: 0.8, | |
| alphaCurve: 0.52, | |
| minAlpha: 0.88, | |
| maxAlpha: 1, | |
| luminanceLow: 0.03, | |
| luminanceHigh: 0.915, | |
| contrast: 2.09, | |
| baseInk: 0.01, | |
| inkGamma: 0.92, | |
| postCropZoom: 1.05, | |
| postCropOffsetX: 0, | |
| postCropOffsetY: 0, | |
| radiusNoise: 0.08, | |
| radiusNoiseFloor: 0.2, | |
| accentWeight: 0.41, | |
| departureWeight: 0.55, | |
| toneWeight: 0.64, | |
| hueDepartureWeight: 0.39, | |
| saturationDepartureWeight: 0.09, | |
| darkDepartureWeight: 0.39, | |
| nearWhiteGate: 0.08, | |
| paperLightnessGate: 0.885, | |
| baseLightnessLift: 0.03, | |
| dotLightnessLift: 0.08, | |
| baseSaturationScale: 0.84, | |
| dotSaturationScale: 1.18, | |
| posterWeight: 0.82, | |
| posterThreshold: 0.46, | |
| posterSoftness: 0.15, | |
| edgeWeight: 0.43, | |
| edgeRadius: 3, | |
| weaveBias: 0.08, | |
| toneBias: 1.3, | |
| hueBias: 0.34, | |
| minInterleave: 0.12, | |
| maxInterleave: 0.78, | |
| }; | |
| export function shareHalftoneSamplePoint(x, y, width, height, sampleWidth, sampleHeight, rule = SHARE_HALFTONE_DOT_RULE) { | |
| return { | |
| sx: clamp(Math.floor((x / width) * sampleWidth), 0, sampleWidth - 1), | |
| sy: clamp(Math.floor((y / height) * sampleHeight), 0, sampleHeight - 1), | |
| }; | |
| } | |
| export function shareHalftoneCropRect(width, height, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const zoom = Math.max(1, rule.postCropZoom || 1); | |
| const sw = width / zoom; | |
| const sh = height / zoom; | |
| const centerX = (width - sw) / 2; | |
| const centerY = (height - sh) / 2; | |
| const sx = clamp(centerX + centerX * (rule.postCropOffsetX || 0), 0, width - sw); | |
| const sy = clamp(centerY + centerY * (rule.postCropOffsetY || 0), 0, height - sh); | |
| return { sx, sy, sw, sh }; | |
| } | |
| export function shareHalftoneDotRadius(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const safeTone = printableTone(radiusTone, rule); | |
| if (safeTone === 0) return 0; | |
| const maxRadius = rule.step * rule.maxRadiusRatio; | |
| const radiusRange = Math.max(0, maxRadius - rule.minRadius); | |
| return rule.minRadius + Math.pow(safeTone, rule.sizeCurve) * radiusRange; | |
| } | |
| export function shareHalftoneDotAlpha(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const safeTone = printableTone(radiusTone, rule); | |
| if (safeTone === 0) return 0; | |
| return 1; | |
| } | |
| export function shareHalftoneNoisyTone(radiusTone, x, y, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const safeTone = clamp(radiusTone, 0, 1); | |
| const midtoneWeight = 1 - Math.abs(safeTone - 0.5) * 2; | |
| const noiseWeight = rule.radiusNoiseFloor + (1 - rule.radiusNoiseFloor) * Math.max(0, midtoneWeight); | |
| const noise = halftoneCellNoise(x, y) * 2 - 1; | |
| return clamp(safeTone + noise * rule.radiusNoise * noiseWeight, 0, 1); | |
| } | |
| export function shareHalftonePlateColors(primaryInk, secondaryInk, rule = SHARE_HALFTONE_DOT_RULE) { | |
| return { | |
| baseInk: normalizePlateInk(primaryInk, "base", rule), | |
| dotInk: normalizePlateInk(secondaryInk, "dot", rule), | |
| }; | |
| } | |
| export function shareHalftoneFeatureColorPair(colors) { | |
| if (!colors.length) return []; | |
| if (colors.length === 1) return [colors[0], colors[0]]; | |
| const candidates = colors.filter(isUsableFeatureColor).slice(0, 28); | |
| if (!candidates.length) return [colors[0], colors[0]]; | |
| const baseCandidates = candidates.slice(0, 6); | |
| const topWeight = candidates[0]?.weight || 1; | |
| let bestPair = [candidates[0], candidates[0]]; | |
| let bestScore = -Infinity; | |
| baseCandidates.forEach((base, baseIndex) => { | |
| candidates.forEach((accent) => { | |
| if (accent === base) return; | |
| const hueGap = hueDistance(base.h, accent.h); | |
| if (hueGap < 12) return; | |
| const accentArea = accent.weight / topWeight; | |
| if (accentArea < 0.03) return; | |
| const baseArea = base.weight / topWeight; | |
| const accentPresence = clamp(accentArea / 0.18, 0, 1); | |
| const hueSeparation = clamp((hueGap - 10) / 42, 0, 1); | |
| const substrateBonus = baseIndex === 0 ? 0.12 : 0; | |
| const lightnessContrast = Math.min(Math.abs(base.l - accent.l), 0.42) / 0.42; | |
| const score = baseArea * 0.34 | |
| + accentPresence * 0.24 | |
| + hueSeparation * 0.16 | |
| + accent.s * 0.1 | |
| + lightnessContrast * 0.08 | |
| + substrateBonus; | |
| if (score > bestScore) { | |
| bestScore = score; | |
| bestPair = [base, accent]; | |
| } | |
| }); | |
| }); | |
| return bestPair; | |
| } | |
| export function shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const hsl = rgbToHsl(r, g, b); | |
| const baseHueFit = hueFit(hsl.h, baseInk.h); | |
| const dotHueFit = hueFit(hsl.h, dotInk.h); | |
| const chroma = clamp((hsl.s - 0.04) / 0.56, 0, 1); | |
| const nearWhitePaper = hsl.l > rule.paperLightnessGate ? rule.nearWhiteGate : 1; | |
| const accentSignal = clamp(0.5 + (dotHueFit - baseHueFit) * 1.55, 0, 1) * (0.55 + chroma * 0.45) * nearWhitePaper; | |
| const hueDeparture = 1 - baseHueFit; | |
| const saturationDeparture = Math.abs(hsl.s - baseInk.s); | |
| const darkDeparture = clamp((baseInk.l - hsl.l) / 0.44, 0, 1); | |
| const structureDeparture = clamp( | |
| hueDeparture * rule.hueDepartureWeight | |
| + saturationDeparture * rule.saturationDepartureWeight | |
| + darkDeparture * rule.darkDepartureWeight, | |
| 0, | |
| 1, | |
| ) * nearWhitePaper; | |
| const structureSignal = dotInk.l >= baseInk.l | |
| ? clamp(luminanceTone, 0, 1) | |
| : 1 - clamp(luminanceTone, 0, 1); | |
| return clamp( | |
| accentSignal * rule.accentWeight | |
| + structureDeparture * rule.departureWeight | |
| + structureSignal * rule.toneWeight | |
| + rule.baseInk, | |
| 0, | |
| 1, | |
| ); | |
| } | |
| export function shareHalftonePosterDotToneForPixel( | |
| pixels, | |
| sx, | |
| sy, | |
| sampleWidth, | |
| sampleHeight, | |
| lowLum, | |
| lumRange, | |
| baseInk, | |
| dotInk, | |
| luminanceTone, | |
| rule = SHARE_HALFTONE_DOT_RULE, | |
| ) { | |
| const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); | |
| const colorTone = shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule); | |
| const structureTone = dotInk.l >= baseInk.l ? luminanceTone : 1 - luminanceTone; | |
| const posterTone = smoothstep( | |
| rule.posterThreshold - rule.posterSoftness, | |
| rule.posterThreshold + rule.posterSoftness, | |
| structureTone, | |
| ); | |
| const edgeTone = shareHalftonePosterEdgeTone( | |
| pixels, | |
| sx, | |
| sy, | |
| sampleWidth, | |
| sampleHeight, | |
| lowLum, | |
| lumRange, | |
| baseInk, | |
| dotInk, | |
| luminanceTone, | |
| rule, | |
| ); | |
| return clamp( | |
| colorTone * (1 - rule.posterWeight) | |
| + posterTone * rule.posterWeight | |
| + edgeTone * rule.edgeWeight, | |
| 0, | |
| 1, | |
| ); | |
| } | |
| export function halftoneInkForPixel(r, g, b, imageInk, primaryInk, secondaryInk, x, y, rule = SHARE_HALFTONE_DOT_RULE) { | |
| const hsl = rgbToHsl(r, g, b); | |
| const ink = clamp(imageInk, 0, 1); | |
| const secondaryHueFit = hueDistance(hsl.h, secondaryInk.h); | |
| const primaryHueFit = hueDistance(hsl.h, primaryInk.h); | |
| const huePreference = hsl.s < 0.055 | |
| ? 0 | |
| : clamp((primaryHueFit - secondaryHueFit) / 180, -1, 1) * rule.hueBias; | |
| const cellPreference = halftoneCellWeave(x, y, rule.step) ? rule.weaveBias : -rule.weaveBias; | |
| const tonePreference = (0.5 - ink) * rule.toneBias; | |
| const secondaryChance = clamp( | |
| 0.5 + cellPreference + tonePreference + huePreference, | |
| rule.minInterleave, | |
| rule.maxInterleave, | |
| ); | |
| return halftoneCellNoise(x, y) < secondaryChance ? secondaryInk : primaryInk; | |
| } | |
| function halftoneCellWeave(x, y, step) { | |
| const cellX = Math.floor(x / step); | |
| const cellY = Math.floor(y / step); | |
| return (cellX + cellY) % 2 === 0; | |
| } | |
| function halftoneCellNoise(x, y) { | |
| const seed = Math.sin(Math.floor(x) * 12.9898 + Math.floor(y) * 78.233) * 43758.5453; | |
| return seed - Math.floor(seed); | |
| } | |
| function shareHalftonePosterEdgeTone( | |
| pixels, | |
| sx, | |
| sy, | |
| sampleWidth, | |
| sampleHeight, | |
| lowLum, | |
| lumRange, | |
| baseInk, | |
| dotInk, | |
| centerLuminanceTone, | |
| rule, | |
| ) { | |
| const centerShape = generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule); | |
| const radius = Math.max(1, Math.round(rule.edgeRadius || 1)); | |
| const offsets = [ | |
| [-radius, 0], | |
| [radius, 0], | |
| [0, -radius], | |
| [0, radius], | |
| ]; | |
| const maxEdge = offsets.reduce((peak, [dx, dy]) => { | |
| const neighborTone = generatedLuminanceToneAt(pixels, sx + dx, sy + dy, sampleWidth, sampleHeight, lowLum, lumRange, rule); | |
| const neighborShape = generatedDotToneAt( | |
| pixels, | |
| sx + dx, | |
| sy + dy, | |
| sampleWidth, | |
| sampleHeight, | |
| lowLum, | |
| lumRange, | |
| baseInk, | |
| dotInk, | |
| rule, | |
| ); | |
| return Math.max( | |
| peak, | |
| Math.abs(centerLuminanceTone - neighborTone) * 0.72 + Math.abs(centerShape - neighborShape) * 0.28, | |
| ); | |
| }, 0); | |
| return clamp(maxEdge * 1.8, 0, 1); | |
| } | |
| function generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule) { | |
| const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); | |
| const luminanceTone = generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule); | |
| return shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule); | |
| } | |
| function generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule) { | |
| const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); | |
| const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; | |
| const normalized = clamp((luminance - lowLum) / lumRange, 0, 1); | |
| return clamp((normalized - 0.5) * rule.contrast + 0.5, 0, 1); | |
| } | |
| function sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight) { | |
| const x = clamp(Math.round(sx), 0, sampleWidth - 1); | |
| const y = clamp(Math.round(sy), 0, sampleHeight - 1); | |
| const offset = (y * sampleWidth + x) * 4; | |
| return { | |
| r: pixels[offset], | |
| g: pixels[offset + 1], | |
| b: pixels[offset + 2], | |
| }; | |
| } | |
| function normalizePlateInk(color, role, rule) { | |
| const hsl = color.h === undefined || color.s === undefined || color.l === undefined | |
| ? rgbToHsl(color.r, color.g, color.b) | |
| : color; | |
| const saturation = role === "base" | |
| ? hsl.s < 0.16 | |
| ? clamp(hsl.s * 1.02, 0.04, 0.16) | |
| : clamp(hsl.s * rule.baseSaturationScale, 0.18, 0.5) | |
| : hsl.s < 0.18 | |
| ? clamp(hsl.s * rule.dotSaturationScale, 0.04, 0.18) | |
| : clamp(hsl.s * rule.dotSaturationScale, 0.22, 0.74); | |
| const lightness = role === "base" | |
| ? clamp(hsl.l + rule.baseLightnessLift, 0.38, 0.82) | |
| : hsl.l < 0.2 | |
| ? clamp(hsl.l + rule.dotLightnessLift * 0.22, 0.08, 0.28) | |
| : clamp(hsl.l + rule.dotLightnessLift, 0.48, 0.92); | |
| const [r, g, b] = hslToRgb(hsl.h, saturation, lightness); | |
| return { r, g, b, h: hsl.h, s: saturation, l: lightness }; | |
| } | |
| function smoothstep(edge0, edge1, value) { | |
| if (edge0 === edge1) return value >= edge1 ? 1 : 0; | |
| const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); | |
| return t * t * (3 - 2 * t); | |
| } | |
| function printableTone(radiusTone, rule) { | |
| const safeTone = clamp(radiusTone, 0, 1); | |
| if (safeTone < rule.paperThreshold) return 0; | |
| return clamp((safeTone - rule.paperThreshold) / (1 - rule.paperThreshold), 0, 1); | |
| } | |
| function rgbToHsl(r, g, b) { | |
| const red = r / 255; | |
| const green = g / 255; | |
| const blue = b / 255; | |
| const max = Math.max(red, green, blue); | |
| const min = Math.min(red, green, blue); | |
| let hue = 0; | |
| let saturation = 0; | |
| const lightness = (max + min) / 2; | |
| if (max !== min) { | |
| const delta = max - min; | |
| saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); | |
| if (max === red) { | |
| hue = (green - blue) / delta + (green < blue ? 6 : 0); | |
| } else if (max === green) { | |
| hue = (blue - red) / delta + 2; | |
| } else { | |
| hue = (red - green) / delta + 4; | |
| } | |
| hue *= 60; | |
| } | |
| return { h: hue, s: saturation, l: lightness }; | |
| } | |
| function hueDistance(firstHue, secondHue) { | |
| const difference = Math.abs(firstHue - secondHue) % 360; | |
| return Math.min(difference, 360 - difference); | |
| } | |
| function hueFit(firstHue, secondHue) { | |
| return 1 - clamp(hueDistance(firstHue, secondHue) / 90, 0, 1); | |
| } | |
| function isUsableFeatureColor(color) { | |
| return color.l < 0.94; | |
| } | |
| function hslToRgb(hue, saturation, lightness) { | |
| const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; | |
| const huePrime = hue / 60; | |
| const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); | |
| let red = 0; | |
| let green = 0; | |
| let blue = 0; | |
| if (huePrime < 1) { | |
| red = chroma; | |
| green = x; | |
| } else if (huePrime < 2) { | |
| red = x; | |
| green = chroma; | |
| } else if (huePrime < 3) { | |
| green = chroma; | |
| blue = x; | |
| } else if (huePrime < 4) { | |
| green = x; | |
| blue = chroma; | |
| } else if (huePrime < 5) { | |
| red = x; | |
| blue = chroma; | |
| } else { | |
| red = chroma; | |
| blue = x; | |
| } | |
| const match = lightness - chroma / 2; | |
| return [ | |
| Math.round((red + match) * 255), | |
| Math.round((green + match) * 255), | |
| Math.round((blue + match) * 255), | |
| ]; | |
| } | |
| function clamp(value, min, max) { | |
| return Math.min(max, Math.max(min, value)); | |
| } | |