react-native-parity-blur 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 (109) hide show
  1. package/LICENSE +20 -0
  2. package/ParityBlur.podspec +20 -0
  3. package/README.md +198 -0
  4. package/android/build.gradle +57 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/parityblur/AndroidBlurCalibration.kt +82 -0
  7. package/android/src/main/java/com/parityblur/BlurEngine.kt +132 -0
  8. package/android/src/main/java/com/parityblur/ParityBlurDebug.kt +24 -0
  9. package/android/src/main/java/com/parityblur/ParityBlurPackage.kt +17 -0
  10. package/android/src/main/java/com/parityblur/ParityBlurView.kt +542 -0
  11. package/android/src/main/java/com/parityblur/ParityBlurViewManager.kt +161 -0
  12. package/android/src/main/java/com/parityblur/PipelineMath.kt +202 -0
  13. package/android/src/main/java/com/parityblur/PixelCopySnapshotProvider.kt +108 -0
  14. package/android/src/main/java/com/parityblur/SoftwareSnapshotProvider.kt +173 -0
  15. package/android/src/main/java/com/parityblur/WindowBlurContext.kt +116 -0
  16. package/android/src/test/java/com/parityblur/MiniJson.kt +179 -0
  17. package/android/src/test/java/com/parityblur/PipelineFixturesTest.kt +222 -0
  18. package/ios/BlurEngine.swift +106 -0
  19. package/ios/ColorPipeline.swift +72 -0
  20. package/ios/MetalPresentationSurface.swift +51 -0
  21. package/ios/ParityBlurCoreView.swift +382 -0
  22. package/ios/ParityBlurDebug.swift +18 -0
  23. package/ios/ParityBlurView.h +14 -0
  24. package/ios/ParityBlurView.mm +135 -0
  25. package/ios/PipelineMath.swift +177 -0
  26. package/ios/SnapshotProvider.swift +128 -0
  27. package/ios/WindowBlurContext.swift +111 -0
  28. package/lib/module/BlurView.js +124 -0
  29. package/lib/module/BlurView.js.map +1 -0
  30. package/lib/module/ParityBlurViewNativeComponent.ts +82 -0
  31. package/lib/module/defaults.js +13 -0
  32. package/lib/module/defaults.js.map +1 -0
  33. package/lib/module/index.js +5 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/pipeline/androidCalibration.js +55 -0
  37. package/lib/module/pipeline/androidCalibration.js.map +1 -0
  38. package/lib/module/pipeline/captureRect.js +151 -0
  39. package/lib/module/pipeline/captureRect.js.map +1 -0
  40. package/lib/module/pipeline/color.js +82 -0
  41. package/lib/module/pipeline/color.js.map +1 -0
  42. package/lib/module/pipeline/constants.js +65 -0
  43. package/lib/module/pipeline/constants.js.map +1 -0
  44. package/lib/module/pipeline/downsample.js +61 -0
  45. package/lib/module/pipeline/downsample.js.map +1 -0
  46. package/lib/module/pipeline/index.js +23 -0
  47. package/lib/module/pipeline/index.js.map +1 -0
  48. package/lib/module/pipeline/overlay.js +56 -0
  49. package/lib/module/pipeline/overlay.js.map +1 -0
  50. package/lib/module/pipeline/saturation.js +52 -0
  51. package/lib/module/pipeline/saturation.js.map +1 -0
  52. package/lib/module/pipeline/types.js +23 -0
  53. package/lib/module/pipeline/types.js.map +1 -0
  54. package/lib/module/pipeline/units.js +33 -0
  55. package/lib/module/pipeline/units.js.map +1 -0
  56. package/lib/module/presets.js +96 -0
  57. package/lib/module/presets.js.map +1 -0
  58. package/lib/module/types.js +4 -0
  59. package/lib/module/types.js.map +1 -0
  60. package/lib/typescript/package.json +1 -0
  61. package/lib/typescript/src/BlurView.d.ts +16 -0
  62. package/lib/typescript/src/BlurView.d.ts.map +1 -0
  63. package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts +63 -0
  64. package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts.map +1 -0
  65. package/lib/typescript/src/defaults.d.ts +12 -0
  66. package/lib/typescript/src/defaults.d.ts.map +1 -0
  67. package/lib/typescript/src/index.d.ts +5 -0
  68. package/lib/typescript/src/index.d.ts.map +1 -0
  69. package/lib/typescript/src/pipeline/androidCalibration.d.ts +42 -0
  70. package/lib/typescript/src/pipeline/androidCalibration.d.ts.map +1 -0
  71. package/lib/typescript/src/pipeline/captureRect.d.ts +84 -0
  72. package/lib/typescript/src/pipeline/captureRect.d.ts.map +1 -0
  73. package/lib/typescript/src/pipeline/color.d.ts +19 -0
  74. package/lib/typescript/src/pipeline/color.d.ts.map +1 -0
  75. package/lib/typescript/src/pipeline/constants.d.ts +52 -0
  76. package/lib/typescript/src/pipeline/constants.d.ts.map +1 -0
  77. package/lib/typescript/src/pipeline/downsample.d.ts +31 -0
  78. package/lib/typescript/src/pipeline/downsample.d.ts.map +1 -0
  79. package/lib/typescript/src/pipeline/index.d.ts +20 -0
  80. package/lib/typescript/src/pipeline/index.d.ts.map +1 -0
  81. package/lib/typescript/src/pipeline/overlay.d.ts +31 -0
  82. package/lib/typescript/src/pipeline/overlay.d.ts.map +1 -0
  83. package/lib/typescript/src/pipeline/saturation.d.ts +38 -0
  84. package/lib/typescript/src/pipeline/saturation.d.ts.map +1 -0
  85. package/lib/typescript/src/pipeline/types.d.ts +32 -0
  86. package/lib/typescript/src/pipeline/types.d.ts.map +1 -0
  87. package/lib/typescript/src/pipeline/units.d.ts +23 -0
  88. package/lib/typescript/src/pipeline/units.d.ts.map +1 -0
  89. package/lib/typescript/src/presets.d.ts +57 -0
  90. package/lib/typescript/src/presets.d.ts.map +1 -0
  91. package/lib/typescript/src/types.d.ts +116 -0
  92. package/lib/typescript/src/types.d.ts.map +1 -0
  93. package/package.json +167 -0
  94. package/src/BlurView.tsx +195 -0
  95. package/src/ParityBlurViewNativeComponent.ts +82 -0
  96. package/src/defaults.ts +20 -0
  97. package/src/index.ts +10 -0
  98. package/src/pipeline/androidCalibration.ts +64 -0
  99. package/src/pipeline/captureRect.ts +172 -0
  100. package/src/pipeline/color.ts +66 -0
  101. package/src/pipeline/constants.ts +65 -0
  102. package/src/pipeline/downsample.ts +74 -0
  103. package/src/pipeline/index.ts +20 -0
  104. package/src/pipeline/overlay.ts +46 -0
  105. package/src/pipeline/saturation.ts +65 -0
  106. package/src/pipeline/types.ts +37 -0
  107. package/src/pipeline/units.ts +32 -0
  108. package/src/presets.ts +103 -0
  109. package/src/types.ts +132 -0
@@ -0,0 +1,202 @@
1
+ package com.parityblur
2
+
3
+ import kotlin.math.ceil
4
+ import kotlin.math.floor
5
+ import kotlin.math.max
6
+ import kotlin.math.min
7
+
8
+ /**
9
+ * Kotlin mirror of `src/pipeline/units.ts`, `src/pipeline/captureRect.ts`,
10
+ * `src/pipeline/downsample.ts`, `src/pipeline/saturation.ts`, and the `sourceOver` half of
11
+ * `src/pipeline/overlay.ts`. This is the executable form of docs/PIPELINE_SPEC.md §1-§4, §7 on
12
+ * the Android side; `android/src/test/.../PipelineFixturesTest.kt` asserts every function here
13
+ * against `test/pipeline-fixtures.json` within 1e-6.
14
+ *
15
+ * Pure Double/Int arithmetic, NO android.* imports -- testable as a plain JVM unit test.
16
+ *
17
+ * Deviation note (documented, not a pipeline behavior change): `overlay.ts`'s `parseColor` is
18
+ * NOT mirrored here. Android's `overlayColor` native prop is a `ColorValue` already parsed into
19
+ * an ARGB Int by React Native's own color parser before it reaches native code (see
20
+ * ParityBlurViewNativeComponent.ts / ParityBlurViewManager), so there is no string-parsing step
21
+ * on the native side to mirror. `sourceOver` IS mirrored below for the fixture suite even though
22
+ * the runtime presentation path does not call it directly -- see ParityBlurView's overlay
23
+ * comment for why a plain Canvas draw of the (already-parsed) overlay color is mathematically
24
+ * equivalent source-over compositing on the opaque blurred destination.
25
+ */
26
+ object PipelineMath {
27
+
28
+ /** Axis-aligned rectangle, target-local device px unless documented otherwise. */
29
+ data class Rect(val x: Double, val y: Double, val width: Double, val height: Double)
30
+
31
+ /** Straight-alpha RGBA colour, channels normalised to [0, 1]. */
32
+ data class RGBA(val r: Double, val g: Double, val b: Double, val a: Double)
33
+
34
+ // ---------------------------------------------------------------- units.ts
35
+
36
+ /** blurRadius (sigma in dp) -> sigma in device px. Non-finite/non-positive inputs clamp to 0. */
37
+ fun sigmaPxFromDp(blurRadiusDp: Double, displayScale: Double): Double {
38
+ if (!blurRadiusDp.isFinite() || blurRadiusDp <= 0) return 0.0
39
+ if (!displayScale.isFinite() || displayScale <= 0) return 0.0
40
+ return blurRadiusDp * displayScale
41
+ }
42
+
43
+ /** Device-px sigma -> snapshot-domain sigma given downsample factor D. */
44
+ fun sigmaSnapshotFromPx(sigmaPx: Double, downsample: Int): Double {
45
+ if (!sigmaPx.isFinite() || sigmaPx <= 0) return 0.0
46
+ return sigmaPx / downsample
47
+ }
48
+
49
+ // ----------------------------------------------------------- captureRect.ts
50
+
51
+ /** Gaussian support margin in device px (plan §7): ceil(K * sigmaPx). */
52
+ fun supportMarginPx(sigmaPx: Double, k: Double = AndroidBlurCalibration.CAPTURE_SUPPORT_K): Double {
53
+ if (!sigmaPx.isFinite() || sigmaPx <= 0) return 0.0
54
+ return ceil(k * sigmaPx)
55
+ }
56
+
57
+ /** Intersection of two rects; zero-size rect if they do not overlap. */
58
+ fun intersectRect(a: Rect, b: Rect): Rect {
59
+ val x0 = max(a.x, b.x)
60
+ val y0 = max(a.y, b.y)
61
+ val x1 = min(a.x + a.width, b.x + b.width)
62
+ val y1 = min(a.y + a.height, b.y + b.height)
63
+ val width = max(0.0, x1 - x0)
64
+ val height = max(0.0, y1 - y0)
65
+ if (width == 0.0 || height == 0.0) return Rect(x0, y0, 0.0, 0.0)
66
+ return Rect(x0, y0, width, height)
67
+ }
68
+
69
+ /**
70
+ * Expand the visible blur rect by the Gaussian support margin and clamp to the target bounds
71
+ * (plan §7). CLAMP handles the outer edge where the margin was cut off (plan §8).
72
+ */
73
+ fun expandCaptureRect(
74
+ visibleRect: Rect,
75
+ targetBounds: Rect,
76
+ sigmaPx: Double,
77
+ k: Double = AndroidBlurCalibration.CAPTURE_SUPPORT_K
78
+ ): Rect {
79
+ val margin = supportMarginPx(sigmaPx, k)
80
+ val expanded = Rect(
81
+ x = visibleRect.x - margin,
82
+ y = visibleRect.y - margin,
83
+ width = visibleRect.width + 2 * margin,
84
+ height = visibleRect.height + 2 * margin
85
+ )
86
+ return intersectRect(expanded, targetBounds)
87
+ }
88
+
89
+ /** Area of a rect in device px^2 (downsample-selection helper). */
90
+ fun rectArea(r: Rect): Double = r.width * r.height
91
+
92
+ /**
93
+ * Map a clamped device-px capture rect into integer snapshot pixels for downsample factor D.
94
+ * LOCKED rounding rule (PIPELINE_SPEC §3.3): origin FLOORS, far edge CEILS (conservative
95
+ * cover) -- every device pixel of the capture rect is guaranteed to land in the snapshot rect.
96
+ */
97
+ fun snapshotRectFor(captureRectPx: Rect, downsample: Int): Rect {
98
+ val x = floor(captureRectPx.x / downsample)
99
+ val y = floor(captureRectPx.y / downsample)
100
+ val farX = ceil((captureRectPx.x + captureRectPx.width) / downsample)
101
+ val farY = ceil((captureRectPx.y + captureRectPx.height) / downsample)
102
+ return Rect(x, y, max(0.0, farX - x), max(0.0, farY - y))
103
+ }
104
+
105
+ /**
106
+ * Fractional snapshot-px crop rect selecting the visible region out of the (larger) snapshot
107
+ * rect after blur. The fractional origin remainder in [0,1) snapshot px is resolved by the
108
+ * bilinear upsample -- do not round it away (PIPELINE_SPEC §3.4).
109
+ */
110
+ fun cropRectFor(visibleRect: Rect, snapshotRect: Rect, downsample: Int): Rect {
111
+ return Rect(
112
+ x = visibleRect.x / downsample - snapshotRect.x,
113
+ y = visibleRect.y / downsample - snapshotRect.y,
114
+ width = visibleRect.width / downsample,
115
+ height = visibleRect.height / downsample
116
+ )
117
+ }
118
+
119
+ // ------------------------------------------------------------ downsample.ts
120
+
121
+ private val DOWNSAMPLE_FACTORS = intArrayOf(8, 4, 2, 1)
122
+
123
+ /** Largest allowed factor in {8,4,2,1} that is <= cap (never below 1). */
124
+ private fun largestFactorAtMost(cap: Double): Int {
125
+ for (factor in DOWNSAMPLE_FACTORS) {
126
+ if (factor <= cap) return factor
127
+ }
128
+ return 1
129
+ }
130
+
131
+ /** Per-quality ceiling on the downsample factor (plan §13). */
132
+ fun qualityMaxDownsample(quality: String): Int = when (quality) {
133
+ "high" -> AndroidBlurCalibration.QUALITY_MAX_DOWNSAMPLE_HIGH
134
+ "performance" -> AndroidBlurCalibration.QUALITY_MAX_DOWNSAMPLE_PERFORMANCE
135
+ else -> AndroidBlurCalibration.QUALITY_MAX_DOWNSAMPLE_BALANCED
136
+ }
137
+
138
+ /**
139
+ * Resolve the auto-selected downsample factor: the largest factor in {8,4,2,1} satisfying ALL
140
+ * of the quality ceiling, the sigma floor, and the small-capture area ceiling
141
+ * (PIPELINE_SPEC §4).
142
+ */
143
+ fun autoDownsample(sigmaPx: Double, captureAreaPx: Double, quality: String): Int {
144
+ if (!sigmaPx.isFinite() || sigmaPx <= 0) return 1
145
+
146
+ val maxByQuality = qualityMaxDownsample(quality)
147
+ val maxBySigma = floor(sigmaPx / AndroidBlurCalibration.MIN_SIGMA_SNAPSHOT)
148
+ val area = if (captureAreaPx.isFinite()) captureAreaPx else 0.0
149
+ val maxByArea = if (area < AndroidBlurCalibration.SMALL_CAPTURE_AREA_PX) 2.0 else 8.0
150
+
151
+ val cap = minOf(maxByQuality.toDouble(), maxBySigma, maxByArea)
152
+ return largestFactorAtMost(cap)
153
+ }
154
+
155
+ /**
156
+ * Resolve the public `downsample` prop (0 = 'auto' sentinel; see
157
+ * ParityBlurViewNativeComponent.ts) to a concrete factor.
158
+ */
159
+ fun resolveDownsample(prop: Int, sigmaPx: Double, captureAreaPx: Double, quality: String): Int {
160
+ if (prop == 0) return autoDownsample(sigmaPx, captureAreaPx, quality)
161
+ return prop
162
+ }
163
+
164
+ // ------------------------------------------------------------- saturation.ts
165
+
166
+ /**
167
+ * Row-major 4x5 saturation matrix (20 numbers), matching Android's ColorMatrix layout and
168
+ * mirroring `saturation.ts` exactly. `s = 1` -> identity; `s = 0` -> luminance grayscale.
169
+ */
170
+ fun saturationMatrix(
171
+ s: Double,
172
+ lr: Double = AndroidBlurCalibration.LUMA_R,
173
+ lg: Double = AndroidBlurCalibration.LUMA_G,
174
+ lb: Double = AndroidBlurCalibration.LUMA_B
175
+ ): DoubleArray {
176
+ val t = 1 - s
177
+ return doubleArrayOf(
178
+ t * lr + s, t * lg, t * lb, 0.0, 0.0,
179
+ t * lr, t * lg + s, t * lb, 0.0, 0.0,
180
+ t * lr, t * lg, t * lb + s, 0.0, 0.0,
181
+ 0.0, 0.0, 0.0, 1.0, 0.0
182
+ )
183
+ }
184
+
185
+ /** Float32 copy of [saturationMatrix], the shape android.graphics.ColorMatrix expects. */
186
+ fun saturationMatrixFloat(s: Double): FloatArray =
187
+ FloatArray(20) { saturationMatrix(s)[it].toFloat() }
188
+
189
+ // --------------------------------------------------------------- overlay.ts
190
+
191
+ /**
192
+ * Straight-alpha source-over composite: `src` over `dst`. Mirrors `overlay.ts` `sourceOver`
193
+ * exactly. Exercised by the fixture suite; the runtime presentation path uses a direct Canvas
194
+ * draw instead (see deviation note above).
195
+ */
196
+ fun sourceOver(src: RGBA, dst: RGBA): RGBA {
197
+ val outA = src.a + dst.a * (1 - src.a)
198
+ if (outA <= 0) return RGBA(0.0, 0.0, 0.0, 0.0)
199
+ fun blend(sc: Double, dc: Double) = (sc * src.a + dc * dst.a * (1 - src.a)) / outA
200
+ return RGBA(blend(src.r, dst.r), blend(src.g, dst.g), blend(src.b, dst.b), outA)
201
+ }
202
+ }
@@ -0,0 +1,108 @@
1
+ package com.parityblur
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Rect
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.view.PixelCopy
8
+ import android.view.ViewGroup
9
+ import android.view.Window
10
+ import com.facebook.react.uimanager.ThemedReactContext
11
+ import kotlin.math.roundToInt
12
+
13
+ /**
14
+ * GPU-composited first-capture provider (M5 finding, amending plan §14.1 a second time).
15
+ *
16
+ * The software-canvas provider cannot rasterize HARDWARE bitmaps (Fresco decodes RN `Image`s to
17
+ * Config.HARDWARE on modern devices; drawing one into a software Canvas is skipped), so any
18
+ * image-backed backdrop went missing from captures — observed on-device as black blur strips
19
+ * over an Image fixture. [PixelCopy] reads the SurfaceFlinger-composited window content instead:
20
+ * hardware bitmaps, TextureViews, everything on screen — and scales into a smaller destination
21
+ * bitmap, giving us the downsample step for free.
22
+ *
23
+ * Constraint: PixelCopy sees the FULL composited frame, including this blur view's own output —
24
+ * so it is only safe while the view has never presented anything (first capture; the
25
+ * presentation node is empty and children have not yet been committed above an empty region...
26
+ * children ARE composited, so the capture may include children committed in the same region).
27
+ * Callers therefore use it ONLY for the first capture after mount, scheduled before the view's
28
+ * first presentation reaches the screen; RE-captures keep the software provider (its
29
+ * hardware-bitmap gap on recapture is a documented v1 limitation, plan §43).
30
+ *
31
+ * Async by nature: results arrive on the main handler; callers must re-validate generation and
32
+ * attachment before presenting (plan §23 — this is exactly what generation tokens exist for).
33
+ */
34
+ object PixelCopySnapshotProvider {
35
+
36
+ fun capture(
37
+ view: ParityBlurView,
38
+ target: ViewGroup,
39
+ blurRadiusDp: Double,
40
+ downsampleProp: Int,
41
+ quality: String,
42
+ engine: BlurEngine,
43
+ onResult: (SoftwareSnapshotProvider.Result?) -> Unit
44
+ ) {
45
+ val window = windowFor(view)
46
+ if (window == null) {
47
+ onResult(null)
48
+ return
49
+ }
50
+ val plan = SoftwareSnapshotProvider.computePlan(view, target, blurRadiusDp, downsampleProp, quality)
51
+ if (plan == null) {
52
+ onResult(null)
53
+ return
54
+ }
55
+
56
+ val d = plan.downsample
57
+ // Snapshot rect in WINDOW device px (snapshot px * D, offset by the target's position).
58
+ val decor = window.decorView
59
+ val srcRect = Rect(
60
+ (plan.targetLocX + plan.snapshotRect.x * d).roundToInt(),
61
+ (plan.targetLocY + plan.snapshotRect.y * d).roundToInt(),
62
+ (plan.targetLocX + (plan.snapshotRect.x + plan.snapshotRect.width) * d).roundToInt(),
63
+ (plan.targetLocY + (plan.snapshotRect.y + plan.snapshotRect.height) * d).roundToInt()
64
+ )
65
+ // The ceil'd far edge can overhang the window by < D px; clamp (edge error <= 1 snapshot px
66
+ // inside the gaussian support margin -- invisible, and CLAMP tiling covers the boundary).
67
+ if (!srcRect.intersect(Rect(0, 0, decor.width, decor.height))) {
68
+ onResult(null)
69
+ return
70
+ }
71
+
72
+ val bitmap = engine.acquireBitmap(plan.snapW, plan.snapH)
73
+ try {
74
+ PixelCopy.request(
75
+ window,
76
+ srcRect,
77
+ bitmap,
78
+ { copyResult ->
79
+ if (copyResult == PixelCopy.SUCCESS) {
80
+ onResult(
81
+ SoftwareSnapshotProvider.Result(
82
+ bitmap = bitmap,
83
+ cropRect = plan.cropRect,
84
+ downsample = plan.downsample,
85
+ noBlur = plan.noBlur,
86
+ radiusPlatform = plan.radiusPlatform
87
+ )
88
+ )
89
+ } else {
90
+ engine.releaseBitmap(bitmap)
91
+ onResult(null)
92
+ }
93
+ },
94
+ Handler(Looper.getMainLooper())
95
+ )
96
+ } catch (t: Throwable) {
97
+ // e.g. IllegalArgumentException("Window doesn't have a backing surface") during teardown.
98
+ engine.releaseBitmap(bitmap)
99
+ onResult(null)
100
+ }
101
+ }
102
+
103
+ private fun windowFor(view: ParityBlurView): Window? {
104
+ val ctx = view.context
105
+ val activity = (ctx as? ThemedReactContext)?.currentActivity ?: ctx as? Activity
106
+ return activity?.window
107
+ }
108
+ }
@@ -0,0 +1,173 @@
1
+ package com.parityblur
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.view.ViewGroup
6
+ import java.util.Collections
7
+ import java.util.WeakHashMap
8
+ import kotlin.math.max
9
+
10
+ /**
11
+ * Marker for software (in-tree) capture passes (plan §14.2; M0-REPORT finding 1).
12
+ *
13
+ * A software [Canvas] makes [ViewGroup] call each child's `draw(Canvas)`, where
14
+ * [ParityBlurView]'s override can bail out for the duration of the pass. On the HARDWARE path
15
+ * `ViewGroup.dispatchDraw` instead records a LIVE REFERENCE to each child's RenderNode without
16
+ * ever calling `draw(Canvas)` -- a draw-override exclusion is invisible there, and an in-tree
17
+ * RenderNode capture forms a reference cycle that crashes the RenderThread (SIGSEGV,
18
+ * device-verified in M0 on a Pixel 6a). Static v1 therefore captures ONLY via this software
19
+ * provider (plan §14.1 amendment); a structural (RenderNode) live provider is Milestone 6 scope
20
+ * and requires the blur view to sit OUTSIDE the captured subtree by construction.
21
+ */
22
+ object CapturePass {
23
+ @Volatile
24
+ var active: Boolean = false
25
+ private set
26
+
27
+ /** Every registered ParityBlurView presentation surface bails out of draw() during a pass. */
28
+ val registered: MutableSet<ParityBlurView> = Collections.newSetFromMap(WeakHashMap())
29
+
30
+ fun <T> run(block: () -> T): T {
31
+ check(!active) { "nested capture pass" }
32
+ active = true
33
+ try {
34
+ return block()
35
+ } finally {
36
+ active = false
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The static-mode capture provider (plan §14.1 as amended by M0-REPORT): rasterize the capture
43
+ * target into a downsampled ARGB_8888 bitmap via a software Canvas (draw-override exclusion
44
+ * works there; no RenderNode references exist, so no cycle is possible), producing a frozen
45
+ * copy -- true static semantics. GPU blur/saturation are applied afterward by the caller via a
46
+ * RenderNode carrying a RenderEffect chain on the resulting bitmap
47
+ * ([ParityBlurView.presentCapture]).
48
+ */
49
+ object SoftwareSnapshotProvider {
50
+
51
+ /** Everything [ParityBlurView] needs to build/refresh its presentation RenderNode. */
52
+ class Result(
53
+ val bitmap: Bitmap,
54
+ val cropRect: PipelineMath.Rect,
55
+ val downsample: Int,
56
+ val noBlur: Boolean,
57
+ val radiusPlatform: Float
58
+ )
59
+
60
+ /**
61
+ * Capture [target] cropped/expanded around [view]'s current on-screen bounds (pipeline steps
62
+ * 1-7, PIPELINE_SPEC §9) and return a downsampled bitmap plus the crop rect needed to present
63
+ * just the visible region back out. Returns null when the view/target are not presently in a
64
+ * capturable state -- callers gate this behind [ParityBlurView]'s state machine (plan §14.4),
65
+ * but this function re-checks defensively since it is the last word before allocating a bitmap.
66
+ */
67
+ /**
68
+ * The geometry every provider shares (PIPELINE_SPEC §3): identical math regardless of HOW the
69
+ * pixels are captured, so provider choice can never change blur geometry.
70
+ */
71
+ internal class Plan(
72
+ val snapshotRect: PipelineMath.Rect,
73
+ val cropRect: PipelineMath.Rect,
74
+ val downsample: Int,
75
+ val snapW: Int,
76
+ val snapH: Int,
77
+ val targetLocX: Int,
78
+ val targetLocY: Int,
79
+ val noBlur: Boolean,
80
+ val radiusPlatform: Float
81
+ )
82
+
83
+ internal fun computePlan(
84
+ view: ParityBlurView,
85
+ target: ViewGroup,
86
+ blurRadiusDp: Double,
87
+ downsampleProp: Int,
88
+ quality: String
89
+ ): Plan? {
90
+ if (!view.isAttachedToWindow || view.width <= 0 || view.height <= 0) return null
91
+ if (target.width <= 0 || target.height <= 0) return null
92
+
93
+ val density = view.resources.displayMetrics.density.toDouble()
94
+ val sigmaPx = PipelineMath.sigmaPxFromDp(blurRadiusDp, density)
95
+
96
+ val viewLoc = IntArray(2)
97
+ val targetLoc = IntArray(2)
98
+ view.getLocationInWindow(viewLoc)
99
+ target.getLocationInWindow(targetLoc)
100
+ // Target-local device px. getLocationInWindow() already reflects ancestor scroll offsets and
101
+ // transforms applied up to the window, so no separate scroll-offset bookkeeping is needed
102
+ // (plan §18 coordinate conversion: view -> window -> target-local).
103
+ val visibleRect = PipelineMath.Rect(
104
+ x = (viewLoc[0] - targetLoc[0]).toDouble(),
105
+ y = (viewLoc[1] - targetLoc[1]).toDouble(),
106
+ width = view.width.toDouble(),
107
+ height = view.height.toDouble()
108
+ )
109
+ val targetBounds = PipelineMath.Rect(0.0, 0.0, target.width.toDouble(), target.height.toDouble())
110
+
111
+ val captureRectPx = PipelineMath.expandCaptureRect(visibleRect, targetBounds, sigmaPx)
112
+ if (captureRectPx.width <= 0 || captureRectPx.height <= 0) return null
113
+
114
+ val downsample = PipelineMath.resolveDownsample(
115
+ downsampleProp,
116
+ sigmaPx,
117
+ PipelineMath.rectArea(captureRectPx),
118
+ quality
119
+ )
120
+ val snapshotRect = PipelineMath.snapshotRectFor(captureRectPx, downsample)
121
+ val cropRect = PipelineMath.cropRectFor(visibleRect, snapshotRect, downsample)
122
+ val sigmaSnapshot = PipelineMath.sigmaSnapshotFromPx(sigmaPx, downsample)
123
+ val blurParam = AndroidBlurCalibration.radiusForSigma(sigmaSnapshot)
124
+
125
+ return Plan(
126
+ snapshotRect = snapshotRect,
127
+ cropRect = cropRect,
128
+ downsample = downsample,
129
+ snapW = max(1, snapshotRect.width.toInt()),
130
+ snapH = max(1, snapshotRect.height.toInt()),
131
+ targetLocX = targetLoc[0],
132
+ targetLocY = targetLoc[1],
133
+ noBlur = blurParam.noBlur,
134
+ radiusPlatform = blurParam.radiusPlatform.toFloat()
135
+ )
136
+ }
137
+
138
+ fun capture(
139
+ view: ParityBlurView,
140
+ target: ViewGroup,
141
+ blurRadiusDp: Double,
142
+ downsampleProp: Int,
143
+ quality: String,
144
+ engine: BlurEngine
145
+ ): Result? {
146
+ val plan = computePlan(view, target, blurRadiusDp, downsampleProp, quality) ?: return null
147
+ val snapshotRect = plan.snapshotRect
148
+ val cropRect = plan.cropRect
149
+ val downsample = plan.downsample
150
+ val snapW = plan.snapW
151
+ val snapH = plan.snapH
152
+
153
+ val bitmap = engine.acquireBitmap(snapW, snapH)
154
+ val softCanvas = Canvas(bitmap)
155
+ // LOCKED rounding rule (PIPELINE_SPEC §3.3): the snapshot origin is FLOORED, so the canvas
156
+ // must translate by the floored*D device-px origin -- NOT the raw (possibly fractional)
157
+ // capture-rect origin -- to keep the bitmap's pixel (0,0) aligned with snapshotRect (0,0).
158
+ softCanvas.scale(1f / downsample, 1f / downsample)
159
+ softCanvas.translate(
160
+ (-(snapshotRect.x * downsample)).toFloat(),
161
+ (-(snapshotRect.y * downsample)).toFloat()
162
+ )
163
+ CapturePass.run { target.draw(softCanvas) }
164
+
165
+ return Result(
166
+ bitmap = bitmap,
167
+ cropRect = cropRect,
168
+ downsample = downsample,
169
+ noBlur = plan.noBlur,
170
+ radiusPlatform = plan.radiusPlatform
171
+ )
172
+ }
173
+ }
@@ -0,0 +1,116 @@
1
+ package com.parityblur
2
+
3
+ import android.view.Choreographer
4
+ import android.view.View
5
+ import android.view.ViewTreeObserver
6
+ import java.util.Collections
7
+ import java.util.WeakHashMap
8
+
9
+ /**
10
+ * Per-window active-view registry + capture scheduling (plan §4, §14.3, §14.4, §20, §21, §39).
11
+ * One instance per native window (keyed by the window's root view in [BlurEngine]) --
12
+ * NEVER one scheduler per [ParityBlurView] (plan §45.6).
13
+ *
14
+ * STATIC mode: capture requests (refresh(), layout/prop changes) coalesce to ONE capture per
15
+ * view at the next frame boundary via [Choreographer.postFrameCallback] -- never synchronously
16
+ * inside JS-command dispatch, never invalidating from preDraw (plan §45.8).
17
+ *
18
+ * LIVE mode (Milestone 6): ONE [ViewTreeObserver.OnPreDrawListener] per window, installed only
19
+ * while at least one live view is registered and removed when the last leaves (plan §24: zero
20
+ * scheduler work when no live blur is active). Each pre-draw tick:
21
+ * 1. filters views by the visibility heuristic (plan §24),
22
+ * 2. applies the per-view maxFps throttle (plan §21: respect elapsed time),
23
+ * 3. recaptures INSIDE the in-flight frame -- the re-recorded RenderNode propagates its own
24
+ * damage, so no invalidate() is issued from preDraw (M0-proven; plan §45).
25
+ * Captures here are synchronous software captures (see [ParityBlurView.performLiveCapture]),
26
+ * so backpressure (plan §22) is inherent: at most one capture per view per frame, and a slow
27
+ * frame simply delays the next tick -- no queue can form.
28
+ */
29
+ class WindowBlurContext(private val rootView: View) {
30
+
31
+ /** All currently-attached ParityBlurView instances in this window (weak, for bookkeeping). */
32
+ private val activeViews: MutableSet<ParityBlurView> =
33
+ Collections.newSetFromMap(WeakHashMap())
34
+
35
+ private val pendingCaptures = LinkedHashSet<ParityBlurView>()
36
+ private var frameCallbackScheduled = false
37
+
38
+ private val frameCallback = Choreographer.FrameCallback {
39
+ frameCallbackScheduled = false
40
+ // Snapshot-and-clear before iterating: a capture triggered from within this callback
41
+ // schedules a NEW frame instead of mutating the set being iterated right now.
42
+ val batch = pendingCaptures.toList()
43
+ pendingCaptures.clear()
44
+ for (view in batch) {
45
+ if (view.isAttachedToWindow) {
46
+ view.performScheduledCapture()
47
+ }
48
+ }
49
+ }
50
+
51
+ // ------------------------------------------------------------------- live (Milestone 6)
52
+
53
+ private val liveViews: MutableSet<ParityBlurView> =
54
+ Collections.newSetFromMap(WeakHashMap())
55
+ private var preDrawInstalled = false
56
+
57
+ private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
58
+ val now = System.nanoTime()
59
+ for (view in liveViews) {
60
+ if (view.isLiveEligible(now)) {
61
+ view.performLiveCapture(now)
62
+ }
63
+ }
64
+ true
65
+ }
66
+
67
+ fun register(view: ParityBlurView) {
68
+ activeViews.add(view)
69
+ }
70
+
71
+ fun unregister(view: ParityBlurView) {
72
+ activeViews.remove(view)
73
+ pendingCaptures.remove(view)
74
+ unregisterLive(view)
75
+ }
76
+
77
+ fun registerLive(view: ParityBlurView) {
78
+ liveViews.add(view)
79
+ if (!preDrawInstalled) {
80
+ val vto = rootView.viewTreeObserver
81
+ if (vto.isAlive) {
82
+ vto.addOnPreDrawListener(preDrawListener)
83
+ preDrawInstalled = true
84
+ ParityBlurDebug.log { "scheduler-install window=${System.identityHashCode(rootView)}" }
85
+ }
86
+ }
87
+ }
88
+
89
+ fun unregisterLive(view: ParityBlurView) {
90
+ liveViews.remove(view)
91
+ if (liveViews.isEmpty() && preDrawInstalled) {
92
+ val vto = rootView.viewTreeObserver
93
+ if (vto.isAlive) vto.removeOnPreDrawListener(preDrawListener)
94
+ preDrawInstalled = false
95
+ ParityBlurDebug.log { "scheduler-uninstall window=${System.identityHashCode(rootView)}" }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Coalesce a capture request for [view] to the next valid frame boundary. Repeated calls
101
+ * before the frame fires collapse into one capture (plan §14.4/§20).
102
+ */
103
+ fun scheduleCapture(view: ParityBlurView) {
104
+ pendingCaptures.add(view)
105
+ if (!frameCallbackScheduled) {
106
+ frameCallbackScheduled = true
107
+ Choreographer.getInstance().postFrameCallback(frameCallback)
108
+ }
109
+ }
110
+
111
+ fun cancelScheduledCapture(view: ParityBlurView) {
112
+ pendingCaptures.remove(view)
113
+ }
114
+
115
+ fun isEmpty(): Boolean = activeViews.isEmpty()
116
+ }