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,179 @@
|
|
|
1
|
+
package com.parityblur
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal, dependency-free JSON parser used only by [PipelineFixturesTest] to load
|
|
5
|
+
* `test/pipeline-fixtures.json`. Deliberately hand-rolled (no Gson/Moshi/org.json dependency) so
|
|
6
|
+
* the Milestone 3 unit-test setup does not need a new external dependency resolved at build
|
|
7
|
+
* time -- the fixture file's shape (objects/arrays/numbers/strings/booleans, no unicode escapes
|
|
8
|
+
* beyond the basics) is fully covered by this small recursive-descent parser.
|
|
9
|
+
*
|
|
10
|
+
* Parses into plain Kotlin values: `Map<String, Any?>`, `List<Any?>`, `Double`, `String`,
|
|
11
|
+
* `Boolean`, or `null`.
|
|
12
|
+
*/
|
|
13
|
+
object MiniJson {
|
|
14
|
+
|
|
15
|
+
fun parse(text: String): Any? {
|
|
16
|
+
val p = Parser(text)
|
|
17
|
+
p.skipWhitespace()
|
|
18
|
+
val value = p.parseValue()
|
|
19
|
+
p.skipWhitespace()
|
|
20
|
+
check(p.atEnd()) { "Trailing content after JSON value at index ${p.pos}" }
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private class Parser(private val s: String) {
|
|
25
|
+
var pos = 0
|
|
26
|
+
|
|
27
|
+
fun atEnd() = pos >= s.length
|
|
28
|
+
|
|
29
|
+
fun skipWhitespace() {
|
|
30
|
+
while (pos < s.length && s[pos].isWhitespace()) pos++
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fun parseValue(): Any? {
|
|
34
|
+
skipWhitespace()
|
|
35
|
+
require(pos < s.length) { "Unexpected end of JSON input" }
|
|
36
|
+
return when (s[pos]) {
|
|
37
|
+
'{' -> parseObject()
|
|
38
|
+
'[' -> parseArray()
|
|
39
|
+
'"' -> parseString()
|
|
40
|
+
't' -> parseLiteral("true", true)
|
|
41
|
+
'f' -> parseLiteral("false", false)
|
|
42
|
+
'n' -> parseLiteral("null", null)
|
|
43
|
+
else -> parseNumber()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun parseLiteral(literal: String, value: Any?): Any? {
|
|
48
|
+
require(s.startsWith(literal, pos)) { "Expected '$literal' at index $pos" }
|
|
49
|
+
pos += literal.length
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun parseObject(): Map<String, Any?> {
|
|
54
|
+
val map = LinkedHashMap<String, Any?>()
|
|
55
|
+
pos++ // '{'
|
|
56
|
+
skipWhitespace()
|
|
57
|
+
if (pos < s.length && s[pos] == '}') {
|
|
58
|
+
pos++
|
|
59
|
+
return map
|
|
60
|
+
}
|
|
61
|
+
while (true) {
|
|
62
|
+
skipWhitespace()
|
|
63
|
+
val key = parseString()
|
|
64
|
+
skipWhitespace()
|
|
65
|
+
require(pos < s.length && s[pos] == ':') { "Expected ':' at index $pos" }
|
|
66
|
+
pos++
|
|
67
|
+
val value = parseValue()
|
|
68
|
+
map[key] = value
|
|
69
|
+
skipWhitespace()
|
|
70
|
+
require(pos < s.length) { "Unterminated object" }
|
|
71
|
+
when (s[pos]) {
|
|
72
|
+
',' -> {
|
|
73
|
+
pos++
|
|
74
|
+
}
|
|
75
|
+
'}' -> {
|
|
76
|
+
pos++
|
|
77
|
+
return map
|
|
78
|
+
}
|
|
79
|
+
else -> throw IllegalArgumentException("Expected ',' or '}' at index $pos")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private fun parseArray(): List<Any?> {
|
|
85
|
+
val list = ArrayList<Any?>()
|
|
86
|
+
pos++ // '['
|
|
87
|
+
skipWhitespace()
|
|
88
|
+
if (pos < s.length && s[pos] == ']') {
|
|
89
|
+
pos++
|
|
90
|
+
return list
|
|
91
|
+
}
|
|
92
|
+
while (true) {
|
|
93
|
+
list.add(parseValue())
|
|
94
|
+
skipWhitespace()
|
|
95
|
+
require(pos < s.length) { "Unterminated array" }
|
|
96
|
+
when (s[pos]) {
|
|
97
|
+
',' -> {
|
|
98
|
+
pos++
|
|
99
|
+
}
|
|
100
|
+
']' -> {
|
|
101
|
+
pos++
|
|
102
|
+
return list
|
|
103
|
+
}
|
|
104
|
+
else -> throw IllegalArgumentException("Expected ',' or ']' at index $pos")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun parseString(): String {
|
|
110
|
+
require(pos < s.length && s[pos] == '"') { "Expected string at index $pos" }
|
|
111
|
+
pos++
|
|
112
|
+
val sb = StringBuilder()
|
|
113
|
+
while (true) {
|
|
114
|
+
require(pos < s.length) { "Unterminated string" }
|
|
115
|
+
val c = s[pos]
|
|
116
|
+
when {
|
|
117
|
+
c == '"' -> {
|
|
118
|
+
pos++
|
|
119
|
+
return sb.toString()
|
|
120
|
+
}
|
|
121
|
+
c == '\\' -> {
|
|
122
|
+
pos++
|
|
123
|
+
require(pos < s.length) { "Unterminated escape" }
|
|
124
|
+
when (val esc = s[pos]) {
|
|
125
|
+
'"' -> sb.append('"')
|
|
126
|
+
'\\' -> sb.append('\\')
|
|
127
|
+
'/' -> sb.append('/')
|
|
128
|
+
'b' -> sb.append('\b')
|
|
129
|
+
'f' -> sb.append('\u000C')
|
|
130
|
+
'n' -> sb.append('\n')
|
|
131
|
+
'r' -> sb.append('\r')
|
|
132
|
+
't' -> sb.append('\t')
|
|
133
|
+
'u' -> {
|
|
134
|
+
val hex = s.substring(pos + 1, pos + 5)
|
|
135
|
+
sb.append(hex.toInt(16).toChar())
|
|
136
|
+
pos += 4
|
|
137
|
+
}
|
|
138
|
+
else -> throw IllegalArgumentException("Invalid escape '\\$esc' at index $pos")
|
|
139
|
+
}
|
|
140
|
+
pos++
|
|
141
|
+
}
|
|
142
|
+
else -> {
|
|
143
|
+
sb.append(c)
|
|
144
|
+
pos++
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun parseNumber(): Double {
|
|
151
|
+
val start = pos
|
|
152
|
+
if (pos < s.length && (s[pos] == '-' || s[pos] == '+')) pos++
|
|
153
|
+
while (pos < s.length && (s[pos].isDigit() || s[pos] == '.' || s[pos] == 'e' || s[pos] == 'E' ||
|
|
154
|
+
s[pos] == '+' || s[pos] == '-')
|
|
155
|
+
) {
|
|
156
|
+
pos++
|
|
157
|
+
}
|
|
158
|
+
val token = s.substring(start, pos)
|
|
159
|
+
require(token.isNotEmpty()) { "Expected number at index $start" }
|
|
160
|
+
return token.toDouble()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -------------------------------------------------------------------- convenience accessors
|
|
166
|
+
|
|
167
|
+
@Suppress("UNCHECKED_CAST")
|
|
168
|
+
fun Any?.asJsonObject(): Map<String, Any?> = this as Map<String, Any?>
|
|
169
|
+
|
|
170
|
+
@Suppress("UNCHECKED_CAST")
|
|
171
|
+
fun Any?.asJsonArray(): List<Any?> = this as List<Any?>
|
|
172
|
+
|
|
173
|
+
fun Any?.asJsonDouble(): Double = this as Double
|
|
174
|
+
|
|
175
|
+
fun Any?.asJsonInt(): Int = (this as Double).toInt()
|
|
176
|
+
|
|
177
|
+
fun Any?.asJsonString(): String = this as String
|
|
178
|
+
|
|
179
|
+
fun Any?.asJsonBoolean(): Boolean = this as Boolean
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package com.parityblur
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads `test/pipeline-fixtures.json` (the language-neutral fixture table shared with the JS
|
|
10
|
+
* suite, docs/PIPELINE_SPEC.md §11) and asserts every [PipelineMath] / [AndroidBlurCalibration]
|
|
11
|
+
* function matches it within 1e-6, per this milestone's required-tests brief.
|
|
12
|
+
*
|
|
13
|
+
* `parseColor` fixtures are intentionally NOT asserted here: Android's `overlayColor` native prop
|
|
14
|
+
* arrives already parsed into an ARGB Int by React Native's own color parser (see
|
|
15
|
+
* ParityBlurViewNativeComponent.ts), so there is no Kotlin string-color-parsing step to mirror
|
|
16
|
+
* (see the deviation note atop PipelineMath.kt).
|
|
17
|
+
*/
|
|
18
|
+
class PipelineFixturesTest {
|
|
19
|
+
|
|
20
|
+
private val fixtures: Map<String, Any?> by lazy {
|
|
21
|
+
val file = locateFixturesFile()
|
|
22
|
+
MiniJson.parse(file.readText()).asJsonObject()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private val TOL = 1e-6
|
|
26
|
+
|
|
27
|
+
private fun rows(name: String): List<Map<String, Any?>> =
|
|
28
|
+
(fixtures[name] ?: error("Missing fixture group '$name'"))
|
|
29
|
+
.asJsonArray()
|
|
30
|
+
.map { it.asJsonObject() }
|
|
31
|
+
|
|
32
|
+
private fun rectOf(map: Map<String, Any?>): PipelineMath.Rect = PipelineMath.Rect(
|
|
33
|
+
x = map.getValue("x").asJsonDouble(),
|
|
34
|
+
y = map.getValue("y").asJsonDouble(),
|
|
35
|
+
width = map.getValue("width").asJsonDouble(),
|
|
36
|
+
height = map.getValue("height").asJsonDouble()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
private fun assertRectEquals(expected: PipelineMath.Rect, actual: PipelineMath.Rect, label: String) {
|
|
40
|
+
assertEquals("$label.x", expected.x, actual.x, TOL)
|
|
41
|
+
assertEquals("$label.y", expected.y, actual.y, TOL)
|
|
42
|
+
assertEquals("$label.width", expected.width, actual.width, TOL)
|
|
43
|
+
assertEquals("$label.height", expected.height, actual.height, TOL)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ------------------------------------------------------------------------------ units.ts
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun sigmaPxFromDp() {
|
|
50
|
+
for (row in rows("sigmaPxFromDp")) {
|
|
51
|
+
val actual = PipelineMath.sigmaPxFromDp(
|
|
52
|
+
row.getValue("blurRadiusDp").asJsonDouble(),
|
|
53
|
+
row.getValue("displayScale").asJsonDouble()
|
|
54
|
+
)
|
|
55
|
+
assertEquals(row.toString(), row.getValue("expected").asJsonDouble(), actual, TOL)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
fun sigmaSnapshotFromPx() {
|
|
61
|
+
for (row in rows("sigmaSnapshotFromPx")) {
|
|
62
|
+
val actual = PipelineMath.sigmaSnapshotFromPx(
|
|
63
|
+
row.getValue("sigmaPx").asJsonDouble(),
|
|
64
|
+
row.getValue("downsample").asJsonInt()
|
|
65
|
+
)
|
|
66
|
+
assertEquals(row.toString(), row.getValue("expected").asJsonDouble(), actual, TOL)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ----------------------------------------------------------------- androidCalibration.ts
|
|
71
|
+
|
|
72
|
+
@Test
|
|
73
|
+
fun radiusForSigma() {
|
|
74
|
+
for (row in rows("radiusForSigma")) {
|
|
75
|
+
val expected = row.getValue("expected").asJsonObject()
|
|
76
|
+
val actual = AndroidBlurCalibration.radiusForSigma(row.getValue("sigmaSnapshot").asJsonDouble())
|
|
77
|
+
assertEquals("$row noBlur", expected.getValue("noBlur").asJsonBoolean(), actual.noBlur)
|
|
78
|
+
assertEquals(
|
|
79
|
+
"$row radiusPlatform",
|
|
80
|
+
expected.getValue("radiusPlatform").asJsonDouble(),
|
|
81
|
+
actual.radiusPlatform,
|
|
82
|
+
TOL
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --------------------------------------------------------------------- downsample.ts
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun autoDownsample() {
|
|
91
|
+
for (row in rows("autoDownsample")) {
|
|
92
|
+
val actual = PipelineMath.autoDownsample(
|
|
93
|
+
row.getValue("sigmaPx").asJsonDouble(),
|
|
94
|
+
row.getValue("captureAreaPx").asJsonDouble(),
|
|
95
|
+
row.getValue("quality").asJsonString()
|
|
96
|
+
)
|
|
97
|
+
assertEquals(row.toString(), row.getValue("expected").asJsonInt(), actual)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------------- captureRect.ts
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun supportMarginPx() {
|
|
105
|
+
for (row in rows("supportMarginPx")) {
|
|
106
|
+
val actual = PipelineMath.supportMarginPx(row.getValue("sigmaPx").asJsonDouble())
|
|
107
|
+
assertEquals(row.toString(), row.getValue("expected").asJsonDouble(), actual, TOL)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun expandCaptureRect() {
|
|
113
|
+
for (row in rows("expandCaptureRect")) {
|
|
114
|
+
val actual = PipelineMath.expandCaptureRect(
|
|
115
|
+
rectOf(row.getValue("visibleRect").asJsonObject()),
|
|
116
|
+
rectOf(row.getValue("targetBounds").asJsonObject()),
|
|
117
|
+
row.getValue("sigmaPx").asJsonDouble()
|
|
118
|
+
)
|
|
119
|
+
assertRectEquals(rectOf(row.getValue("expected").asJsonObject()), actual, row.toString())
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@Test
|
|
124
|
+
fun snapshotRectFor() {
|
|
125
|
+
for (row in rows("snapshotRectFor")) {
|
|
126
|
+
val actual = PipelineMath.snapshotRectFor(
|
|
127
|
+
rectOf(row.getValue("captureRectPx").asJsonObject()),
|
|
128
|
+
row.getValue("downsample").asJsonInt()
|
|
129
|
+
)
|
|
130
|
+
assertRectEquals(rectOf(row.getValue("expected").asJsonObject()), actual, row.toString())
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Test
|
|
135
|
+
fun cropRectFor() {
|
|
136
|
+
for (row in rows("cropRectFor")) {
|
|
137
|
+
val downsample = row.getValue("downsample").asJsonInt()
|
|
138
|
+
// The fixture provides captureRectPx (not the intermediate snapshotRect); derive the
|
|
139
|
+
// snapshot rect first exactly as the real pipeline sequences it (expand -> snapshotRectFor
|
|
140
|
+
// -> cropRectFor -- see PipelineMath.kt / captureRect.ts buildCapturePlan).
|
|
141
|
+
val snapshotRect = PipelineMath.snapshotRectFor(
|
|
142
|
+
rectOf(row.getValue("captureRectPx").asJsonObject()),
|
|
143
|
+
downsample
|
|
144
|
+
)
|
|
145
|
+
val actual = PipelineMath.cropRectFor(
|
|
146
|
+
rectOf(row.getValue("visibleRect").asJsonObject()),
|
|
147
|
+
snapshotRect,
|
|
148
|
+
downsample
|
|
149
|
+
)
|
|
150
|
+
assertRectEquals(rectOf(row.getValue("expected").asJsonObject()), actual, row.toString())
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -------------------------------------------------------------------- saturation.ts
|
|
155
|
+
|
|
156
|
+
@Test
|
|
157
|
+
fun saturationMatrix() {
|
|
158
|
+
for (row in rows("saturationMatrix")) {
|
|
159
|
+
val expected = row.getValue("expected").asJsonArray().map { it.asJsonDouble() }
|
|
160
|
+
val actual = PipelineMath.saturationMatrix(row.getValue("s").asJsonDouble())
|
|
161
|
+
assertEquals(20, expected.size)
|
|
162
|
+
assertEquals(20, actual.size)
|
|
163
|
+
for (i in 0 until 20) {
|
|
164
|
+
assertEquals("$row [$i]", expected[i], actual[i], TOL)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ----------------------------------------------------------------------- overlay.ts
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
fun sourceOver() {
|
|
173
|
+
for (row in rows("sourceOver")) {
|
|
174
|
+
fun rgba(key: String): PipelineMath.RGBA {
|
|
175
|
+
val m = row.getValue(key).asJsonObject()
|
|
176
|
+
return PipelineMath.RGBA(
|
|
177
|
+
m.getValue("r").asJsonDouble(),
|
|
178
|
+
m.getValue("g").asJsonDouble(),
|
|
179
|
+
m.getValue("b").asJsonDouble(),
|
|
180
|
+
m.getValue("a").asJsonDouble()
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
val expected = rgba("expected")
|
|
184
|
+
val actual = PipelineMath.sourceOver(rgba("src"), rgba("dst"))
|
|
185
|
+
assertEquals("$row r", expected.r, actual.r, TOL)
|
|
186
|
+
assertEquals("$row g", expected.g, actual.g, TOL)
|
|
187
|
+
assertEquals("$row b", expected.b, actual.b, TOL)
|
|
188
|
+
assertEquals("$row a", expected.a, actual.a, TOL)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------------------- fixture lookup
|
|
193
|
+
|
|
194
|
+
private fun locateFixturesFile(): File {
|
|
195
|
+
val cwd = File(System.getProperty("user.dir") ?: ".").absoluteFile
|
|
196
|
+
val candidates = LinkedHashSet<File>()
|
|
197
|
+
candidates += File(cwd, "../test/pipeline-fixtures.json")
|
|
198
|
+
candidates += File(cwd, "test/pipeline-fixtures.json")
|
|
199
|
+
// Defensive fallback: walk upward from the working dir (bounded, not a filesystem scan)
|
|
200
|
+
// in case the test task's working directory differs from the usual module dir.
|
|
201
|
+
var dir: File? = cwd
|
|
202
|
+
var steps = 0
|
|
203
|
+
while (dir != null && steps < 6) {
|
|
204
|
+
candidates += File(dir, "test/pipeline-fixtures.json")
|
|
205
|
+
dir = dir.parentFile
|
|
206
|
+
steps++
|
|
207
|
+
}
|
|
208
|
+
for (candidate in candidates) {
|
|
209
|
+
val resolved = runCatching { candidate.canonicalFile }.getOrNull() ?: continue
|
|
210
|
+
if (resolved.isFile) return resolved
|
|
211
|
+
}
|
|
212
|
+
throw AssertionError(
|
|
213
|
+
"Could not locate test/pipeline-fixtures.json (cwd=$cwd). Checked: " +
|
|
214
|
+
candidates.joinToString { it.path }
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@Test
|
|
219
|
+
fun fixtureFileIsFound() {
|
|
220
|
+
assertTrue(locateFixturesFile().isFile)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Metal
|
|
3
|
+
import MetalPerformanceShaders
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// Process-wide blur engine (plan §15.1, §26, §27).
|
|
7
|
+
///
|
|
8
|
+
/// Created LAZILY the first time an eligible blur view actually needs blur resources — never at
|
|
9
|
+
/// module load, package link, or view init. Reduce-Transparency fallback instances and views
|
|
10
|
+
/// that never reach an eligible capture state must never call `BlurEngine.shared()`.
|
|
11
|
+
final class BlurEngine {
|
|
12
|
+
|
|
13
|
+
private static var _shared: BlurEngine?
|
|
14
|
+
static var isInitialized: Bool { _shared != nil }
|
|
15
|
+
|
|
16
|
+
/// Lazy accessor. Returns nil when Metal is unavailable (the caller falls back to
|
|
17
|
+
/// fallbackColor rendering — plan §25/§27 fallback instances allocate nothing).
|
|
18
|
+
static func shared() -> BlurEngine? {
|
|
19
|
+
if let s = _shared { return s }
|
|
20
|
+
guard let device = MTLCreateSystemDefaultDevice(),
|
|
21
|
+
let queue = device.makeCommandQueue() else {
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
let s = BlurEngine(device: device, queue: queue)
|
|
25
|
+
_shared = s
|
|
26
|
+
ParityBlurDebug.log("engine-init")
|
|
27
|
+
return s
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let device: MTLDevice
|
|
31
|
+
let queue: MTLCommandQueue
|
|
32
|
+
|
|
33
|
+
/// MPS gaussian kernel cache keyed by sigma quantized to 1/16 (bounds cache size; a 1/16-px
|
|
34
|
+
/// sigma step is far below visible threshold). Cleared on memory pressure.
|
|
35
|
+
private var kernelCache: [Int: MPSImageGaussianBlur] = [:]
|
|
36
|
+
|
|
37
|
+
/// Compiled post-process pipeline (crop + saturation + overlay — ColorPipeline.swift).
|
|
38
|
+
private var postPipeline: MTLComputePipelineState?
|
|
39
|
+
private var linearSampler: MTLSamplerState?
|
|
40
|
+
|
|
41
|
+
/// Per-window contexts, weakly keyed so they die with their window (plan §15.2, §26).
|
|
42
|
+
private let windowContexts = NSMapTable<UIWindow, WindowBlurContext>.weakToStrongObjects()
|
|
43
|
+
|
|
44
|
+
private var memoryWarningObserver: NSObjectProtocol?
|
|
45
|
+
|
|
46
|
+
private init(device: MTLDevice, queue: MTLCommandQueue) {
|
|
47
|
+
self.device = device
|
|
48
|
+
self.queue = queue
|
|
49
|
+
memoryWarningObserver = NotificationCenter.default.addObserver(
|
|
50
|
+
forName: UIApplication.didReceiveMemoryWarningNotification,
|
|
51
|
+
object: nil,
|
|
52
|
+
queue: .main
|
|
53
|
+
) { [weak self] _ in
|
|
54
|
+
self?.handleMemoryWarning()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func gaussian(sigma: Float) -> MPSImageGaussianBlur {
|
|
59
|
+
let key = max(1, Int((sigma * 16).rounded()))
|
|
60
|
+
if let k = kernelCache[key] { return k }
|
|
61
|
+
let k = MPSImageGaussianBlur(device: device, sigma: Float(key) / 16.0)
|
|
62
|
+
k.edgeMode = .clamp // spec §5: CLAMP on the clamped outer boundary, both platforms
|
|
63
|
+
kernelCache[key] = k
|
|
64
|
+
return k
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Lazily compiled compute pipeline for the post pass. Returns nil on compile failure
|
|
68
|
+
/// (callers fall back to a plain blit — blur-only, no saturation/overlay — and log once).
|
|
69
|
+
func postProcessPipeline() -> MTLComputePipelineState? {
|
|
70
|
+
if let p = postPipeline { return p }
|
|
71
|
+
do {
|
|
72
|
+
let library = try device.makeLibrary(source: ColorPipeline.metalSource, options: nil)
|
|
73
|
+
guard let fn = library.makeFunction(name: "parityblur_post") else { return nil }
|
|
74
|
+
let p = try device.makeComputePipelineState(function: fn)
|
|
75
|
+
postPipeline = p
|
|
76
|
+
return p
|
|
77
|
+
} catch {
|
|
78
|
+
NSLog("[ParityBlur] post-process kernel compile failed: %@", "\(error)")
|
|
79
|
+
return nil
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func sampler() -> MTLSamplerState? {
|
|
84
|
+
if let s = linearSampler { return s }
|
|
85
|
+
let desc = MTLSamplerDescriptor()
|
|
86
|
+
desc.minFilter = .linear
|
|
87
|
+
desc.magFilter = .linear // spec §3.4/§9.12: bilinear resolves the fractional crop origin
|
|
88
|
+
desc.sAddressMode = .clampToEdge
|
|
89
|
+
desc.tAddressMode = .clampToEdge
|
|
90
|
+
linearSampler = device.makeSamplerState(descriptor: desc)
|
|
91
|
+
return linearSampler
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func windowContext(for window: UIWindow) -> WindowBlurContext {
|
|
95
|
+
if let ctx = windowContexts.object(forKey: window) { return ctx }
|
|
96
|
+
let ctx = WindowBlurContext()
|
|
97
|
+
windowContexts.setObject(ctx, forKey: window)
|
|
98
|
+
return ctx
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func handleMemoryWarning() {
|
|
102
|
+
// Plan §26: trim caches; per-view snapshot buffers are trimmed by their owners via this
|
|
103
|
+
// same notification. Kernels and the pipeline recompile/reallocate lazily on next use.
|
|
104
|
+
kernelCache.removeAll()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import simd
|
|
3
|
+
|
|
4
|
+
/// Post-blur color pass (spec §9 steps 9-11): fractional crop (bilinear), saturation matrix,
|
|
5
|
+
/// overlay source-over — one compute dispatch writing straight into the CAMetalLayer drawable.
|
|
6
|
+
/// All math matches PipelineMath/{saturationMatrix,sourceOver} exactly; the fixture suite pins
|
|
7
|
+
/// the CPU reference, and M5 calibration compares the GPU output cross-platform.
|
|
8
|
+
enum ColorPipeline {
|
|
9
|
+
|
|
10
|
+
/// Uniforms layout shared with the MSL kernel below (float4 alignment).
|
|
11
|
+
struct Uniforms {
|
|
12
|
+
/// Fractional crop origin inside the blurred snapshot texture, snapshot px.
|
|
13
|
+
var cropOrigin: SIMD2<Float>
|
|
14
|
+
var _pad: SIMD2<Float> = .zero
|
|
15
|
+
/// Saturation matrix rows (RGB coefficients only; offsets are all 0 per spec §7).
|
|
16
|
+
var satR: SIMD4<Float>
|
|
17
|
+
var satG: SIMD4<Float>
|
|
18
|
+
var satB: SIMD4<Float>
|
|
19
|
+
/// Overlay color, STRAIGHT alpha (spec §8).
|
|
20
|
+
var overlay: SIMD4<Float>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static func uniforms(
|
|
24
|
+
cropX: Double, cropY: Double, saturation: Double, overlay: PipelineMath.RGBA
|
|
25
|
+
) -> Uniforms {
|
|
26
|
+
let m = PipelineMath.saturationMatrix(saturation)
|
|
27
|
+
return Uniforms(
|
|
28
|
+
cropOrigin: SIMD2(Float(cropX), Float(cropY)),
|
|
29
|
+
satR: SIMD4(Float(m[0]), Float(m[1]), Float(m[2]), 0),
|
|
30
|
+
satG: SIMD4(Float(m[5]), Float(m[6]), Float(m[7]), 0),
|
|
31
|
+
satB: SIMD4(Float(m[10]), Float(m[11]), Float(m[12]), 0),
|
|
32
|
+
overlay: SIMD4(Float(overlay.r), Float(overlay.g), Float(overlay.b), Float(overlay.a))
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// v1 backdrop is opaque (spec §6): source-over reduces to srcC*srcA + dstC*(1-srcA), out a=1.
|
|
37
|
+
/// The sampler is linear so the fractional crop origin is resolved by bilinear filtering
|
|
38
|
+
/// (spec §3.4); reads are raw encoded values (gamma-space, .bgra8Unorm view) so saturation and
|
|
39
|
+
/// overlay composite in the same domain as Android's ColorMatrix/Paint pipeline.
|
|
40
|
+
static let metalSource = """
|
|
41
|
+
#include <metal_stdlib>
|
|
42
|
+
using namespace metal;
|
|
43
|
+
|
|
44
|
+
struct PBUniforms {
|
|
45
|
+
float2 cropOrigin;
|
|
46
|
+
float2 _pad;
|
|
47
|
+
float4 satR;
|
|
48
|
+
float4 satG;
|
|
49
|
+
float4 satB;
|
|
50
|
+
float4 overlay;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
kernel void parityblur_post(
|
|
54
|
+
texture2d<float, access::sample> src [[texture(0)]],
|
|
55
|
+
texture2d<float, access::write> dst [[texture(1)]],
|
|
56
|
+
sampler linearSampler [[sampler(0)]],
|
|
57
|
+
constant PBUniforms &u [[buffer(0)]],
|
|
58
|
+
uint2 gid [[thread_position_in_grid]]) {
|
|
59
|
+
if (gid.x >= dst.get_width() || gid.y >= dst.get_height()) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
float2 srcSize = float2(src.get_width(), src.get_height());
|
|
63
|
+
float2 coord = (u.cropOrigin + float2(gid) + 0.5) / srcSize;
|
|
64
|
+
float3 c = src.sample(linearSampler, coord).rgb;
|
|
65
|
+
float3 sat = clamp(
|
|
66
|
+
float3(dot(c, u.satR.rgb), dot(c, u.satG.rgb), dot(c, u.satB.rgb)),
|
|
67
|
+
0.0, 1.0);
|
|
68
|
+
float3 outc = u.overlay.rgb * u.overlay.a + sat * (1.0 - u.overlay.a);
|
|
69
|
+
dst.write(float4(outc, 1.0), gid);
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// CAMetalLayer-backed presentation surface (plan §15.5): the blurred result is composited by
|
|
4
|
+
/// Core Animation directly from the drawable — zero GPU→CPU readback in the present path
|
|
5
|
+
/// (M0-verified). The layer bilinearly scales drawable pixels up to the view's bounds, which is
|
|
6
|
+
/// the spec §9.12 upsample step.
|
|
7
|
+
final class MetalPresentationView: UIView {
|
|
8
|
+
override class var layerClass: AnyClass { CAMetalLayer.self }
|
|
9
|
+
var metalLayer: CAMetalLayer { layer as! CAMetalLayer }
|
|
10
|
+
|
|
11
|
+
private let maskLayer = CAShapeLayer()
|
|
12
|
+
private var cornerRadii: [CGFloat] = [0, 0, 0, 0] // TL, TR, BR, BL
|
|
13
|
+
|
|
14
|
+
/// Rounded clipping of the blur OUTPUT only (plan §31): children are siblings of this view
|
|
15
|
+
/// and keep normal RN overflow semantics.
|
|
16
|
+
func setCornerRadii(topLeft: CGFloat, topRight: CGFloat, bottomRight: CGFloat, bottomLeft: CGFloat) {
|
|
17
|
+
let next = [topLeft, topRight, bottomRight, bottomLeft]
|
|
18
|
+
guard next != cornerRadii else { return }
|
|
19
|
+
cornerRadii = next
|
|
20
|
+
updateMask()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override func layoutSubviews() {
|
|
24
|
+
super.layoutSubviews()
|
|
25
|
+
updateMask()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private func updateMask() {
|
|
29
|
+
let (tl, tr, br, bl) = (cornerRadii[0], cornerRadii[1], cornerRadii[2], cornerRadii[3])
|
|
30
|
+
if tl <= 0 && tr <= 0 && br <= 0 && bl <= 0 {
|
|
31
|
+
layer.mask = nil
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
let path = CGMutablePath()
|
|
35
|
+
let r = bounds
|
|
36
|
+
guard r.width > 0, r.height > 0 else { return }
|
|
37
|
+
path.move(to: CGPoint(x: r.minX + tl, y: r.minY))
|
|
38
|
+
path.addLine(to: CGPoint(x: r.maxX - tr, y: r.minY))
|
|
39
|
+
path.addArc(tangent1End: CGPoint(x: r.maxX, y: r.minY), tangent2End: CGPoint(x: r.maxX, y: r.minY + tr), radius: max(tr, 0.001))
|
|
40
|
+
path.addLine(to: CGPoint(x: r.maxX, y: r.maxY - br))
|
|
41
|
+
path.addArc(tangent1End: CGPoint(x: r.maxX, y: r.maxY), tangent2End: CGPoint(x: r.maxX - br, y: r.maxY), radius: max(br, 0.001))
|
|
42
|
+
path.addLine(to: CGPoint(x: r.minX + bl, y: r.maxY))
|
|
43
|
+
path.addArc(tangent1End: CGPoint(x: r.minX, y: r.maxY), tangent2End: CGPoint(x: r.minX, y: r.maxY - bl), radius: max(bl, 0.001))
|
|
44
|
+
path.addLine(to: CGPoint(x: r.minX, y: r.minY + tl))
|
|
45
|
+
path.addArc(tangent1End: CGPoint(x: r.minX, y: r.minY), tangent2End: CGPoint(x: r.minX + tl, y: r.minY), radius: max(tl, 0.001))
|
|
46
|
+
path.closeSubpath()
|
|
47
|
+
maskLayer.frame = bounds
|
|
48
|
+
maskLayer.path = path
|
|
49
|
+
layer.mask = maskLayer
|
|
50
|
+
}
|
|
51
|
+
}
|