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.
- package/LICENSE +20 -0
- package/ParityBlur.podspec +20 -0
- package/README.md +198 -0
- package/android/build.gradle +57 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/parityblur/AndroidBlurCalibration.kt +82 -0
- package/android/src/main/java/com/parityblur/BlurEngine.kt +132 -0
- package/android/src/main/java/com/parityblur/ParityBlurDebug.kt +24 -0
- package/android/src/main/java/com/parityblur/ParityBlurPackage.kt +17 -0
- package/android/src/main/java/com/parityblur/ParityBlurView.kt +542 -0
- package/android/src/main/java/com/parityblur/ParityBlurViewManager.kt +161 -0
- package/android/src/main/java/com/parityblur/PipelineMath.kt +202 -0
- package/android/src/main/java/com/parityblur/PixelCopySnapshotProvider.kt +108 -0
- package/android/src/main/java/com/parityblur/SoftwareSnapshotProvider.kt +173 -0
- package/android/src/main/java/com/parityblur/WindowBlurContext.kt +116 -0
- package/android/src/test/java/com/parityblur/MiniJson.kt +179 -0
- package/android/src/test/java/com/parityblur/PipelineFixturesTest.kt +222 -0
- package/ios/BlurEngine.swift +106 -0
- package/ios/ColorPipeline.swift +72 -0
- package/ios/MetalPresentationSurface.swift +51 -0
- package/ios/ParityBlurCoreView.swift +382 -0
- package/ios/ParityBlurDebug.swift +18 -0
- package/ios/ParityBlurView.h +14 -0
- package/ios/ParityBlurView.mm +135 -0
- package/ios/PipelineMath.swift +177 -0
- package/ios/SnapshotProvider.swift +128 -0
- package/ios/WindowBlurContext.swift +111 -0
- package/lib/module/BlurView.js +124 -0
- package/lib/module/BlurView.js.map +1 -0
- package/lib/module/ParityBlurViewNativeComponent.ts +82 -0
- package/lib/module/defaults.js +13 -0
- package/lib/module/defaults.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/pipeline/androidCalibration.js +55 -0
- package/lib/module/pipeline/androidCalibration.js.map +1 -0
- package/lib/module/pipeline/captureRect.js +151 -0
- package/lib/module/pipeline/captureRect.js.map +1 -0
- package/lib/module/pipeline/color.js +82 -0
- package/lib/module/pipeline/color.js.map +1 -0
- package/lib/module/pipeline/constants.js +65 -0
- package/lib/module/pipeline/constants.js.map +1 -0
- package/lib/module/pipeline/downsample.js +61 -0
- package/lib/module/pipeline/downsample.js.map +1 -0
- package/lib/module/pipeline/index.js +23 -0
- package/lib/module/pipeline/index.js.map +1 -0
- package/lib/module/pipeline/overlay.js +56 -0
- package/lib/module/pipeline/overlay.js.map +1 -0
- package/lib/module/pipeline/saturation.js +52 -0
- package/lib/module/pipeline/saturation.js.map +1 -0
- package/lib/module/pipeline/types.js +23 -0
- package/lib/module/pipeline/types.js.map +1 -0
- package/lib/module/pipeline/units.js +33 -0
- package/lib/module/pipeline/units.js.map +1 -0
- package/lib/module/presets.js +96 -0
- package/lib/module/presets.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/BlurView.d.ts +16 -0
- package/lib/typescript/src/BlurView.d.ts.map +1 -0
- package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts +63 -0
- package/lib/typescript/src/ParityBlurViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/defaults.d.ts +12 -0
- package/lib/typescript/src/defaults.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +5 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/androidCalibration.d.ts +42 -0
- package/lib/typescript/src/pipeline/androidCalibration.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/captureRect.d.ts +84 -0
- package/lib/typescript/src/pipeline/captureRect.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/color.d.ts +19 -0
- package/lib/typescript/src/pipeline/color.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/constants.d.ts +52 -0
- package/lib/typescript/src/pipeline/constants.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/downsample.d.ts +31 -0
- package/lib/typescript/src/pipeline/downsample.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/index.d.ts +20 -0
- package/lib/typescript/src/pipeline/index.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/overlay.d.ts +31 -0
- package/lib/typescript/src/pipeline/overlay.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/saturation.d.ts +38 -0
- package/lib/typescript/src/pipeline/saturation.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/types.d.ts +32 -0
- package/lib/typescript/src/pipeline/types.d.ts.map +1 -0
- package/lib/typescript/src/pipeline/units.d.ts +23 -0
- package/lib/typescript/src/pipeline/units.d.ts.map +1 -0
- package/lib/typescript/src/presets.d.ts +57 -0
- package/lib/typescript/src/presets.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +116 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +167 -0
- package/src/BlurView.tsx +195 -0
- package/src/ParityBlurViewNativeComponent.ts +82 -0
- package/src/defaults.ts +20 -0
- package/src/index.ts +10 -0
- package/src/pipeline/androidCalibration.ts +64 -0
- package/src/pipeline/captureRect.ts +172 -0
- package/src/pipeline/color.ts +66 -0
- package/src/pipeline/constants.ts +65 -0
- package/src/pipeline/downsample.ts +74 -0
- package/src/pipeline/index.ts +20 -0
- package/src/pipeline/overlay.ts +46 -0
- package/src/pipeline/saturation.ts +65 -0
- package/src/pipeline/types.ts +37 -0
- package/src/pipeline/units.ts +32 -0
- package/src/presets.ts +103 -0
- 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
|
+
}
|