vireglass 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/android/build.gradle +15 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
  7. package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
  8. package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
  9. package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
  10. package/dist/adaptation-BSbbdExK.d.cts +78 -0
  11. package/dist/adaptation-BSbbdExK.d.ts +78 -0
  12. package/dist/adapters-BPB0D5nr.d.cts +357 -0
  13. package/dist/adapters-BgSc-smX.d.ts +357 -0
  14. package/dist/chunk-HF2AWZEZ.js +1741 -0
  15. package/dist/chunk-HF2AWZEZ.js.map +1 -0
  16. package/dist/chunk-JNRZNMJJ.js +20 -0
  17. package/dist/chunk-JNRZNMJJ.js.map +1 -0
  18. package/dist/index.cjs +2159 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +294 -0
  21. package/dist/index.d.ts +294 -0
  22. package/dist/index.js +286 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/material-CkUHUBoy.d.cts +408 -0
  25. package/dist/material-CkUHUBoy.d.ts +408 -0
  26. package/dist/react.cjs +73 -0
  27. package/dist/react.cjs.map +1 -0
  28. package/dist/react.d.cts +25 -0
  29. package/dist/react.d.ts +25 -0
  30. package/dist/react.js +59 -0
  31. package/dist/react.js.map +1 -0
  32. package/dist/web/index.cjs +1990 -0
  33. package/dist/web/index.cjs.map +1 -0
  34. package/dist/web/index.d.cts +148 -0
  35. package/dist/web/index.d.ts +148 -0
  36. package/dist/web/index.js +657 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/expo-module.config.json +6 -0
  39. package/package.json +127 -0
  40. package/src/accessibility.ts +51 -0
  41. package/src/adaptation.ts +85 -0
  42. package/src/adapters.ts +362 -0
  43. package/src/concentric.ts +25 -0
  44. package/src/geometry.ts +151 -0
  45. package/src/glass-scale.ts +37 -0
  46. package/src/group-model.ts +224 -0
  47. package/src/index.ts +17 -0
  48. package/src/lens-shader.ts +547 -0
  49. package/src/material.ts +553 -0
  50. package/src/native/environment.ts +70 -0
  51. package/src/native/glass-group.tsx +107 -0
  52. package/src/native/glass-ink.tsx +44 -0
  53. package/src/native/glass-surface.tsx +477 -0
  54. package/src/native/index.ts +18 -0
  55. package/src/native/lens.ts +106 -0
  56. package/src/native/provider.tsx +58 -0
  57. package/src/native/surface-registry.ts +51 -0
  58. package/src/native/system-accessibility.ts +90 -0
  59. package/src/optics.ts +177 -0
  60. package/src/react.ts +102 -0
  61. package/src/reference-scene.ts +222 -0
  62. package/src/scroll-edge.ts +30 -0
  63. package/src/sdf.ts +149 -0
  64. package/src/surface-shader.ts +287 -0
  65. package/src/targets/agsl.ts +8 -0
  66. package/src/targets/glsl.ts +140 -0
  67. package/src/touch-response.ts +195 -0
  68. package/src/web/gl.ts +119 -0
  69. package/src/web/index.ts +3 -0
  70. package/src/web/probe.ts +312 -0
  71. package/src/web/reference-draw.ts +142 -0
  72. package/src/web/renderer.ts +524 -0
@@ -0,0 +1,463 @@
1
+ package expo.modules.glasslens
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.RenderEffect
6
+ import android.graphics.RuntimeShader
7
+ import android.os.Build
8
+ import android.graphics.Canvas
9
+ import android.util.Log
10
+ import android.view.View
11
+ import android.view.ViewGroup
12
+ import expo.modules.kotlin.AppContext
13
+ import expo.modules.kotlin.viewevent.EventDispatcher
14
+ import expo.modules.kotlin.views.ExpoView
15
+ import kotlin.math.abs
16
+ import kotlin.math.max
17
+ import kotlin.math.min
18
+
19
+ // Refraction of a live backdrop can't be computed with either a view transform or a Skia shader
20
+ // on top of it: the transform is affine (one coefficient for the whole lens — that's a
21
+ // magnifying glass, not a lens), and a Skia canvas can't see the native backdrop's pixels.
22
+ // `RenderEffect.createRuntimeShaderEffect` is the only way on Android to hand an AGSL shader
23
+ // content the view has ALREADY drawn: a backdrop snapshot arrives here from the BlurView child,
24
+ // and the shader samples it at a shifted coordinate.
25
+ //
26
+ // The shader source arrives as a PROP from JS (`lens-shader.ts`): AGSL and SKSL are one
27
+ // language, so the lens's and the surface's geometry are literally the same string. Keeping a
28
+ // second copy of the SDF here would be exactly the drift this phase closes off.
29
+ @SuppressLint("ViewConstructor")
30
+ class GlassLensView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
31
+ private val density = context.resources.displayMetrics.density
32
+
33
+ /** Lightness, variegation and color of the backdrop under this glass. The app listens to this
34
+ * event to recolor the ink once the glass has worked past its own limit (see the probe in
35
+ * GlassBackdropView). */
36
+ private val onBackdropSample by EventDispatcher()
37
+
38
+ private val screenAt = IntArray(2)
39
+ private val selfAt = IntArray(2)
40
+ private val backdropAt = IntArray(2)
41
+
42
+ // A ViewGroup doesn't draw itself by default, and drawing the backdrop is now this view's job.
43
+ init { setWillNotDraw(false) }
44
+
45
+ private var backdrop: GlassBackdropView? = null
46
+
47
+ /** Tag of the TARGET (the app's own blur-target wrapper), not of the capture itself: a
48
+ * fallback wrapper sits outside for below Android 13, and our GlassBackdropView lies inside
49
+ * it. */
50
+ var backdropId: Int? = null
51
+ set(value) {
52
+ if (field == value) return
53
+ field = value
54
+ backdrop?.unregister(this)
55
+ backdrop = null
56
+ attach(value, RESOLVE_TRIES)
57
+ invalidate()
58
+ }
59
+
60
+ /** Looks for the capture view with retries across frames: the tag arrives before the target
61
+ * manages to register, and the setter won't let a second set of the same tag through anyway. */
62
+ private fun attach(id: Int?, tries: Int) {
63
+ if (id == null || id != backdropId || backdrop != null) return
64
+ backdrop = appContext.findView<View>(id)?.let(::findBackdrop)
65
+ if (backdrop != null) {
66
+ backdrop?.register(this)
67
+ invalidate()
68
+ return
69
+ }
70
+ if (tries > 0) {
71
+ postOnAnimation { attach(id, tries - 1) }
72
+ return
73
+ }
74
+ Log.e("GlassLens", "backdrop $id not found, glass is left without refraction")
75
+ }
76
+
77
+ private fun findBackdrop(view: View, depth: Int = 0): GlassBackdropView? {
78
+ if (view is GlassBackdropView) return view
79
+ if (depth >= 3 || view !is ViewGroup) return null
80
+ for (i in 0 until view.childCount) {
81
+ findBackdrop(view.getChildAt(i), depth + 1)?.let { return it }
82
+ }
83
+ return null
84
+ }
85
+
86
+ private var shader: RuntimeShader? = null
87
+ private var compiledSource: String? = null
88
+
89
+ var shaderSource: String? = null
90
+
91
+ /** Size of the VISIBLE glass in dp: the lightness probe's rectangle is computed from it.
92
+ * Everything else about the material arrives through the shared channel below. */
93
+ var glassWidth = 0f
94
+ var glassHeight = 0f
95
+
96
+ // THE SHARED UNIFORM CHANNEL. There used to be a separate Prop per material value, and adding
97
+ // a phenomenon to the shader meant a Kotlin edit, an APK rebuild, and — on a typo — the lens
98
+ // silently turning off: Expo swallows an unknown prop without a word (material-lab.md E-01,
99
+ // which is how refraction ended up disabled in production for an entire phase). Now name, size
100
+ // and value arrive TOGETHER, and a mismatch with the shader gets logged by name.
101
+ var uniformNames: List<String> = emptyList()
102
+ var uniformValues: List<Double> = emptyList()
103
+ var uniformSizes: List<Int> = emptyList()
104
+
105
+ // Compiling AGSL is expensive and must only happen when the source actually changes: props
106
+ // arrive in a batch on every render, while the string itself stays the same.
107
+ private fun ensureShader(): RuntimeShader? {
108
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
109
+ val src = shaderSource ?: return null
110
+ if (src == compiledSource) return shader
111
+ compiledSource = src
112
+ // A compile error arrives as an exception from the constructor and crashes the view if left
113
+ // uncaught. But it can't be silent either: without the effect, BlurView draws its own
114
+ // RECTANGLE across the whole view, and the symptom looks nothing like the error text.
115
+ shader = try {
116
+ RuntimeShader(src)
117
+ } catch (e: Throwable) {
118
+ Log.e("GlassLens", "AGSL failed to compile, lens is disabled", e)
119
+ null
120
+ }
121
+ return shader
122
+ }
123
+
124
+ /** The single call made after any prop changes — uniforms only take effect through a fresh
125
+ * `setRenderEffect`; mutating the `RuntimeShader` itself doesn't invalidate the view. */
126
+ fun applyEffect() {
127
+ // The view can ONLY be hidden when there's no shader at all: the BlurView inside stops
128
+ // capturing the backdrop if hidden, and doesn't come back to life on its own. A transitional
129
+ // state just waits for the next call.
130
+ val effect = ensureShader()
131
+ if (effect == null) {
132
+ visibility = INVISIBLE
133
+ return
134
+ }
135
+ visibility = VISIBLE
136
+ if (width <= 0 || height <= 0 || glassWidth <= 0f || glassHeight <= 0f) return
137
+
138
+ try {
139
+ // The view sets ONLY what it alone knows: its own size and its own place on screen.
140
+ effect.setFloatUniform("u_center", width / 2f, height / 2f)
141
+ // How far content actually extends: the lens view is wider than the glass by a margin,
142
+ // beyond that it's empty.
143
+ effect.setFloatUniform("u_reach", min(width, height) / 2f)
144
+
145
+ // The capture ends at the screen's edge, while the surface's lens view runs its full width
146
+ // past it. Hand the shader the rectangle where content actually exists, in ITS OWN
147
+ // coordinates — otherwise a band along such edges is left without refraction.
148
+ getLocationOnScreen(screenAt)
149
+ val metrics = resources.displayMetrics
150
+ val minX = maxOf(-screenAt[0], 0).toFloat() + 1f
151
+ val minY = maxOf(-screenAt[1], 0).toFloat() + 1f
152
+ val maxX = minOf(metrics.widthPixels - screenAt[0], width).toFloat() - 1f
153
+ val maxY = minOf(metrics.heightPixels - screenAt[1], height).toFloat() - 1f
154
+ effect.setFloatUniform("u_contentMin", minX, minY)
155
+ effect.setFloatUniform("u_contentMax", maxX, maxY)
156
+ // Backdrop estimate under the glass. It can't be computed in the shader from a handful of
157
+ // taps: body density is a nonlinear function of lightness, and at the kink the estimate's
158
+ // spread between neighboring pixels used to turn into GHOST COPIES of text lying under the
159
+ // glass, one sampling radius away. Here it's one value for the whole surface, coming from
160
+ // the probe and smoothed over time — there's nowhere for a comb artifact to come from.
161
+ effect.setFloatUniform("u_probeLuma", probeLuma)
162
+ effect.setFloatUniform("u_probeBusy", probeBusy)
163
+ effect.setFloatUniform("u_probeRange", probeLo, probeHi)
164
+ effect.setFloatUniform("u_probeSlope", probeSlopeX, probeSlopeY)
165
+ effect.setFloatUniform("u_probe", probeR, probeG, probeB)
166
+ applyChannel(effect)
167
+ } catch (e: Throwable) {
168
+ Log.e("GlassLens", "lens uniforms drifted out of sync with the shader", e)
169
+ setRenderEffect(null)
170
+ return
171
+ }
172
+
173
+ setRenderEffect(RenderEffect.createRuntimeShaderEffect(effect, "content"))
174
+ }
175
+
176
+ /** The shared channel: name, size and values are already reconciled on the JS side
177
+ * (`adapters.ts`). The error isn't swallowed — otherwise the lens turns off silently and the
178
+ * symptom looks like nothing in particular. */
179
+ private fun applyChannel(effect: RuntimeShader) {
180
+ var at = 0
181
+ for (k in uniformNames.indices) {
182
+ val name = uniformNames[k]
183
+ val size = uniformSizes.getOrElse(k) { 1 }
184
+ if (at + size > uniformValues.size) break
185
+ try {
186
+ val v0 = uniformValues[at].toFloat()
187
+ when (size) {
188
+ 1 -> effect.setFloatUniform(name, v0)
189
+ 2 -> effect.setFloatUniform(name, v0, uniformValues[at + 1].toFloat())
190
+ 3 -> effect.setFloatUniform(
191
+ name,
192
+ v0,
193
+ uniformValues[at + 1].toFloat(),
194
+ uniformValues[at + 2].toFloat(),
195
+ )
196
+ 4 -> effect.setFloatUniform(
197
+ name,
198
+ v0,
199
+ uniformValues[at + 1].toFloat(),
200
+ uniformValues[at + 2].toFloat(),
201
+ uniformValues[at + 3].toFloat(),
202
+ )
203
+ }
204
+ } catch (e: Throwable) {
205
+ Log.e("GlassLens", "uniform $name (size $size) was rejected by the shader", e)
206
+ }
207
+ at += size
208
+ }
209
+ }
210
+
211
+ /** The lens's content is a backdrop snapshot, and it draws it itself: `RenderEffect` works off
212
+ * what the view has drawn. The offset is taken from screen position — it accounts for
213
+ * transforms, and when the glass moves, a different patch of backdrop ends up underneath it. */
214
+ override fun onDraw(canvas: Canvas) {
215
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
216
+ val source = backdrop ?: return
217
+ // Registration is idempotent and self-healing: the backdrop can reconnect.
218
+ source.register(this)
219
+ val n = source.content ?: return
220
+ getLocationOnScreen(selfAt)
221
+ source.getLocationOnScreen(backdropAt)
222
+ canvas.save()
223
+ canvas.translate((backdropAt[0] - selfAt[0]).toFloat(), (backdropAt[1] - selfAt[1]).toFloat())
224
+ canvas.drawRenderNode(n)
225
+ canvas.restore()
226
+ }
227
+
228
+ /** The probe has taken a fresh grid. Pull OUR OWN rectangle out of it — the tab bar and the
229
+ * mini-player have different things underneath them — and report outward only if it moved
230
+ * noticeably: an event into JS costs more than the measurement itself. */
231
+ fun onBackdropProbed() {
232
+ val source = backdrop ?: return
233
+ val luma = source.lumaGrid ?: return
234
+ val color = source.colorGrid ?: return
235
+ if (source.width <= 0 || source.height <= 0) return
236
+
237
+ getLocationOnScreen(selfAt)
238
+ source.getLocationOnScreen(backdropAt)
239
+ val halfW = glassWidth * density / 2f
240
+ val halfH = glassHeight * density / 2f
241
+ val cx = (selfAt[0] - backdropAt[0]) + width / 2f
242
+ val cy = (selfAt[1] - backdropAt[1]) + height / 2f
243
+
244
+ val gw = GlassBackdropView.PROBE_W
245
+ val gh = GlassBackdropView.PROBE_H
246
+ val sx = gw / source.width.toFloat()
247
+ val sy = gh / source.height.toFloat()
248
+ val x0 = max(((cx - halfW) * sx).toInt(), 0)
249
+ val x1 = min(((cx + halfW) * sx).toInt() + 1, gw)
250
+ val y0 = max(((cy - halfH) * sy).toInt(), 0)
251
+ val y1 = min(((cy + halfH) * sy).toInt() + 1, gh)
252
+ if (x1 <= x0 || y1 <= y0) return
253
+
254
+ var sum = 0f
255
+ var r = 0f
256
+ var g = 0f
257
+ var b = 0f
258
+ var count = 0
259
+ val bins = IntArray(BINS)
260
+ // Lightness slope across the surface. Tinting must be GRADIENT: where one half under the
261
+ // glass is light and the other dark, one density for the whole element gives no separation
262
+ // on either half. A plane is the crudest model that describes this, and the only one that's
263
+ // smooth by construction: a point estimate at the density kink used to turn into ghost
264
+ // copies of text (material-lab.md E-36).
265
+ var sumU = 0f
266
+ var sumV = 0f
267
+ var uu = 0f
268
+ var vv = 0f
269
+ val midX = (x0 + x1 - 1) * 0.5f
270
+ val midY = (y0 + y1 - 1) * 0.5f
271
+ val halfCellsX = max((x1 - 1 - x0) * 0.5f, 0.5f)
272
+ val halfCellsY = max((y1 - 1 - y0) * 0.5f, 0.5f)
273
+ for (y in y0 until y1) {
274
+ for (x in x0 until x1) {
275
+ val i = y * gw + x
276
+ val l = luma[i]
277
+ sum += l
278
+ bins[((l * (BINS - 1)).toInt()).coerceIn(0, BINS - 1)]++
279
+ val u = (x - midX) / halfCellsX
280
+ val v = (y - midY) / halfCellsY
281
+ sumU += l * u
282
+ sumV += l * v
283
+ uu += u * u
284
+ vv += v * v
285
+ r += color[i * 3]
286
+ g += color[i * 3 + 1]
287
+ b += color[i * 3 + 2]
288
+ count++
289
+ }
290
+ }
291
+ val n = count.toFloat()
292
+ val mean = sum / n
293
+ // The coordinates are centered, so the normal equations decouple: the slope along each axis
294
+ // is computed independently.
295
+ val slopeX = if (uu > 0.001f) sumU / uu else 0f
296
+ val slopeY = if (vv > 0.001f) sumV / vv else 0f
297
+
298
+ // The edges are taken as PERCENTILES, not min/max: a single white dot under the glass's edge
299
+ // shouldn't read as "there's a white field under the glass." Judging by the average is even
300
+ // worse — right over a black/white border it gives a gray that's formally fine, while the
301
+ // ink drowns over the light half.
302
+ var lo = 0f
303
+ var hi = 1f
304
+ var acc = 0
305
+ val loAt = (count * 0.1f).toInt()
306
+ val hiAt = (count * 0.9f).toInt()
307
+ var loSet = false
308
+ for (k in 0 until BINS) {
309
+ val next = acc + bins[k]
310
+ if (!loSet && next > loAt) {
311
+ lo = k / (BINS - 1f)
312
+ loSet = true
313
+ }
314
+ if (next > hiAt) {
315
+ hi = k / (BINS - 1f)
316
+ break
317
+ }
318
+ acc = next
319
+ }
320
+
321
+ // Variegation is the mean deviation from average lightness, doubled (a field that's half
322
+ // black and half white comes out to exactly 1). Range doesn't work for this: a page of dark
323
+ // text gets the same value as a checkerboard, even though light makes up a fraction of a
324
+ // percent of it.
325
+ var dev = 0f
326
+ for (k in 0 until BINS) {
327
+ dev += bins[k] * kotlin.math.abs(k / (BINS - 1f) - mean)
328
+ }
329
+ val busy = (2f * dev / n).coerceIn(0f, 1f)
330
+
331
+ // The smoothing target. The value itself travels to it frame by frame (see `settle`): the
332
+ // probe's step is 180 ms, and without in-between frames the adaptation reads as steps.
333
+ targetLuma = mean
334
+ targetBusy = busy
335
+ targetLo = lo
336
+ targetHi = hi
337
+ targetSlopeX = slopeX
338
+ targetSlopeY = slopeY
339
+ targetR = r / n
340
+ targetG = g / n
341
+ targetB = b / n
342
+ if (probeLuma < 0f) {
343
+ probeLuma = mean
344
+ probeBusy = busy
345
+ probeLo = lo
346
+ probeHi = hi
347
+ probeSlopeX = slopeX
348
+ probeSlopeY = slopeY
349
+ probeR = targetR
350
+ probeG = targetG
351
+ probeB = targetB
352
+ }
353
+ settle()
354
+
355
+ // The event fires on EVERY measurement, not only on change. There used to be a threshold
356
+ // here, and on a static screen events stopped altogether — while the decision to recolor
357
+ // content needs several confirmations in a row and so never accumulated any. The rate is
358
+ // already low (PROBE_INTERVAL_MS); filtering it is the consumer's job.
359
+ emitSample(mean, busy, lo, hi, r / n, g / n, b / n)
360
+ }
361
+
362
+ private fun emitSample(
363
+ luma: Float,
364
+ busy: Float,
365
+ lo: Float,
366
+ hi: Float,
367
+ r: Float,
368
+ g: Float,
369
+ b: Float,
370
+ ) {
371
+ onBackdropSample(
372
+ mapOf("luma" to luma, "busy" to busy, "lo" to lo, "hi" to hi, "r" to r, "g" to g, "b" to b),
373
+ )
374
+ }
375
+
376
+ override fun onAttachedToWindow() {
377
+ super.onAttachedToWindow()
378
+ attach(backdropId, RESOLVE_TRIES)
379
+ }
380
+
381
+ override fun onDetachedFromWindow() {
382
+ backdrop?.unregister(this)
383
+ super.onDetachedFromWindow()
384
+ }
385
+
386
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
387
+ super.onSizeChanged(w, h, oldw, oldh)
388
+ applyEffect()
389
+ }
390
+
391
+
392
+ // Smoothed backdrop estimate. Negative lightness = the probe hasn't reported anything yet,
393
+ // and the shader falls back on its own samples.
394
+ private var probeLuma = -1f
395
+ private var probeBusy = 0f
396
+ private var probeLo = 0f
397
+ private var probeHi = 0f
398
+ private var probeSlopeX = 0f
399
+ private var probeSlopeY = 0f
400
+ private var probeR = 0f
401
+ private var probeG = 0f
402
+ private var probeB = 0f
403
+ private var targetLuma = 0f
404
+ private var targetBusy = 0f
405
+ private var targetLo = 0f
406
+ private var targetHi = 0f
407
+ private var targetSlopeX = 0f
408
+ private var targetSlopeY = 0f
409
+ private var targetR = 0f
410
+ private var targetG = 0f
411
+ private var targetB = 0f
412
+ private var settling = false
413
+
414
+ /** Exponential approach to the latest measurement, frame by frame. Stops on its own once
415
+ * there's nowhere left to go: while the value is holding still, the effect isn't recreated
416
+ * at all. */
417
+ private fun settle() {
418
+ if (settling) return
419
+ settling = true
420
+ post(object : Runnable {
421
+ override fun run() {
422
+ // The condition looks at EVERY quantity the loop moves: on luma alone it used to stall
423
+ // when only the hue of the backdrop changed. Maximum, not sum: the threshold is set for
424
+ // ONE quantity and summing would tighten it with every added term — extra frames of
425
+ // `applyEffect()` for nothing.
426
+ val d = maxOf(
427
+ maxOf(abs(targetLuma - probeLuma), abs(targetBusy - probeBusy), abs(targetLo - probeLo)),
428
+ maxOf(abs(targetHi - probeHi), abs(targetSlopeX - probeSlopeX), abs(targetSlopeY - probeSlopeY)),
429
+ maxOf(abs(targetR - probeR), abs(targetG - probeG), abs(targetB - probeB)),
430
+ )
431
+ probeLuma += (targetLuma - probeLuma) * SETTLE
432
+ probeBusy += (targetBusy - probeBusy) * SETTLE
433
+ probeLo += (targetLo - probeLo) * SETTLE
434
+ probeHi += (targetHi - probeHi) * SETTLE
435
+ probeSlopeX += (targetSlopeX - probeSlopeX) * SETTLE
436
+ probeSlopeY += (targetSlopeY - probeSlopeY) * SETTLE
437
+ probeR += (targetR - probeR) * SETTLE
438
+ probeG += (targetG - probeG) * SETTLE
439
+ probeB += (targetB - probeB) * SETTLE
440
+ applyEffect()
441
+ if (d > SETTLE_EPS) postOnAnimation(this) else settling = false
442
+ }
443
+ })
444
+ }
445
+
446
+ private companion object {
447
+ /** How many frames to wait for the capture view to appear before declaring it absent. */
448
+ const val RESOLVE_TRIES = 10
449
+
450
+ /** Number of histogram bins. Enough for both percentiles and mean deviation: the statistics
451
+ * don't need to be any more precise than this — they drive smoothly varying quantities. */
452
+ const val BINS = 32
453
+
454
+ /** Fraction of the way to the target covered per frame. A time constant of roughly a third
455
+ * of a second — the adaptation has to be unnoticeable, not instant. */
456
+ const val SETTLE = 0.08f
457
+
458
+ /** Below this difference, the value is considered to have arrived, and the frame-by-frame
459
+ * approach stops. */
460
+ const val SETTLE_EPS = 0.002f
461
+ }
462
+ }
463
+
@@ -0,0 +1,76 @@
1
+ package expo.modules.glasslens
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.RenderEffect
6
+ import android.graphics.RuntimeShader
7
+ import android.os.Build
8
+ import android.util.Log
9
+ import expo.modules.kotlin.AppContext
10
+ import expo.modules.kotlin.views.ExpoView
11
+
12
+ // A diagnostic probe for one question: WHICH pixels actually reach the
13
+ // `RenderEffect.createRuntimeShaderEffect` shader. The documentation says "the content of the
14
+ // RenderNode the effect is attached to, together with its children," but all of VireGlass's
15
+ // architecture rests on that claim, so it's checked by experiment, not by quoting the docs.
16
+ //
17
+ // The shader is deliberately trivial: it returns the sample as-is, with no optics. Whatever
18
+ // shows up on screen is exactly what the shader received.
19
+ //
20
+ // Doesn't touch the production GlassLensView: a separate class, a separate view name.
21
+ private const val PROBE_AGSL = """
22
+ uniform shader content;
23
+ uniform float2 u_size;
24
+ uniform float u_mode;
25
+
26
+ half4 main(float2 xy) {
27
+ half4 c = content.eval(xy);
28
+
29
+ // mode 0 — raw sample: shows exactly what arrived in the shader.
30
+ if (u_mode < 0.5) { return c; }
31
+
32
+ // mode 1 — shader-liveness marker: diagonal hatching over the sample.
33
+ // Tells "the shader got transparency" apart from "the shader never applied at all."
34
+ if (u_mode < 1.5) {
35
+ float stripe = step(0.5, fract((xy.x + xy.y) / 40.0));
36
+ half3 marker = half3(1.0, 0.0, 1.0) * half(stripe * 0.35);
37
+ return half4(c.rgb + marker * (1.0 - c.a), max(c.a, half(stripe * 0.35)));
38
+ }
39
+
40
+ // mode 2 — the sample's alpha map, opaque. An unambiguous answer to question §9: white =
41
+ // opaque content reached the shader, black = nothing reached it at all. A midtone within
42
+ // the view's bounds means a semi-transparent capture (a tinted BlurView).
43
+ return half4(c.a, c.a, c.a, 1.0);
44
+ }
45
+ """
46
+
47
+ @SuppressLint("ViewConstructor")
48
+ class GlassProbeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
49
+ private val shader = try {
50
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) RuntimeShader(PROBE_AGSL) else null
51
+ } catch (e: Throwable) {
52
+ Log.e("GlassProbe", "probe AGSL failed to compile", e)
53
+ null
54
+ }
55
+
56
+ /** 0 — raw sample, 1 — sample + shader-liveness marker. */
57
+ var mode = 0f
58
+
59
+ fun applyEffect() {
60
+ val effect = shader ?: run {
61
+ Log.w("GlassProbe", "no shader — effect not applied")
62
+ return
63
+ }
64
+ if (width <= 0 || height <= 0) return
65
+
66
+ effect.setFloatUniform("u_size", width.toFloat(), height.toFloat())
67
+ effect.setFloatUniform("u_mode", mode)
68
+ setRenderEffect(RenderEffect.createRuntimeShaderEffect(effect, "content"))
69
+ Log.i("GlassProbe", "effect applied: ${width}x$height mode=$mode")
70
+ }
71
+
72
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
73
+ super.onSizeChanged(w, h, oldw, oldh)
74
+ applyEffect()
75
+ }
76
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Automatic content polarity above the glass.
3
+ *
4
+ * The glass adjusts its own body to the backdrop — but that has a limit. When the backdrop is
5
+ * light AND the ink over it is light, no amount of body density can separate them by contrast
6
+ * anymore: the body hits its own ceiling, and the letters drown. Past that point the decision is
7
+ * no longer the glass's to make but the app's — it recolors the ink to the opposite side, and all
8
+ * of the glass's own automation reflows behind it, because it depends on that same polarity.
9
+ *
10
+ * The app has nowhere to get the lightness of the backdrop UNDER the glass from — only the native
11
+ * capture can see it. That's where it comes from: the `onBackdropSample` event (a probe in
12
+ * `GlassBackdropView`).
13
+ *
14
+ * The decision is made BY LIGHTNESS with hysteresis: computing body density here with the
15
+ * shader's formula would mean keeping a second implementation of the model that silently drifts
16
+ * from the first. Whether the body and the ink actually separate by contrast is checked by
17
+ * `check:optics` — against a real render, not a copy of the formula.
18
+ */
19
+ type BackdropSample = {
20
+ /** Average lightness of the backdrop under the glass, 0..1, on the same (sRGB-encoded) scale
21
+ * as the screen. */
22
+ luma: number;
23
+ /** Variegation: twice the mean deviation of lightness, 0 on a flat fill, 1 at the limit.
24
+ * Exactly the same quantity the probe computes on the web — otherwise the platforms would
25
+ * drift apart silently. */
26
+ busy: number;
27
+ /** The darkest and lightest spots under the glass. The legibility decision is made from these,
28
+ * not the average: right over a black/white border the average reads as "everything's fine". */
29
+ lo: number;
30
+ hi: number;
31
+ r: number;
32
+ g: number;
33
+ b: number;
34
+ };
35
+ /** The element's ambient color from a probe sample — the one that bleeds into its shadow
36
+ * (docs/reference.md §7). */
37
+ declare function ambientFrom(sample: {
38
+ r: number;
39
+ g: number;
40
+ b: number;
41
+ }): [number, number, number];
42
+ /** Ink lightness at the ends of the scale. The kit keeps light text nearly white and dark text
43
+ * nearly black; there's no in-between polarity state by construction. */
44
+ declare const INK_LIGHT = 0.95;
45
+ declare const INK_DARK = 0.08;
46
+ /**
47
+ * Ink polarity from a backdrop sample — ONE place for every platform. A small element and its
48
+ * glyphs switch between light and dark so that contrast is maximized (docs/reference.md §3): over
49
+ * a yellow flower the reference already has black glyphs on light glass. Holding a light ink with
50
+ * body density over a light backdrop just turns the element into a painted plaque.
51
+ *
52
+ * The decision is by lightness with a bias toward the light side — not by average (a white screen
53
+ * with a dark stripe would otherwise never flip) and not by maximum (one light cover peeking under
54
+ * the edge would recolor the whole panel). Hysteresis lives inside; the caller decides how many
55
+ * confirmations it takes.
56
+ */
57
+ declare function shouldInkBeLight(sample: {
58
+ luma: number;
59
+ hi?: number;
60
+ }, wasLight: boolean): boolean;
61
+ declare const decisiveLuma: (sample: {
62
+ luma: number;
63
+ hi?: number;
64
+ }) => number;
65
+ /** Lighter than this, a light ink flips to dark. */
66
+ declare const FLIP_LUMA = 0.62;
67
+ /** The way back triggers noticeably earlier than the way forward: without the gap the ink would
68
+ * flicker on every light cover sliding under the glass edge. */
69
+ declare const RETURN_LUMA = 0.5;
70
+ /** How many consecutive samples have to demand a change. The probe samples ~5 times a second, so
71
+ * three samples is roughly half a second of a stable backdrop, not a random cover flashing by
72
+ * under the edge. */
73
+ declare const CONFIRMATIONS = 3;
74
+ /** Duration of the recolor. Slow enough not to read as an event, fast enough that the illegible
75
+ * in-between state doesn't linger noticeably. */
76
+ declare const FADE_MS = 420;
77
+
78
+ export { type BackdropSample as B, CONFIRMATIONS as C, FADE_MS as F, INK_DARK as I, RETURN_LUMA as R, FLIP_LUMA as a, INK_LIGHT as b, ambientFrom as c, decisiveLuma as d, shouldInkBeLight as s };