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,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
+ }