com.crossx.capture.sdk.unity 0.0.0-beta.1

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.
@@ -0,0 +1,27 @@
1
+ fileFormatVersion: 2
2
+ guid: 3c1dbd814f47527f95ab7effd66fe950
3
+ PluginImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ iconMap: {}
7
+ executionOrder: {}
8
+ defineConstraints: []
9
+ isPreloaded: 0
10
+ isOverridable: 0
11
+ isExplicitlyReferenced: 0
12
+ validateReferences: 1
13
+ platformData:
14
+ - first:
15
+ Any:
16
+ second:
17
+ enabled: 0
18
+ settings: {}
19
+ - first:
20
+ Editor: Editor
21
+ second:
22
+ enabled: 1
23
+ settings:
24
+ DefaultValueInitialized: true
25
+ userData:
26
+ assetBundleName:
27
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0"?>
2
+ <doc>
3
+ <assembly>
4
+ <name>CROSSx.Capture.Sdk.Unity.Editor</name>
5
+ </assembly>
6
+ <members>
7
+ </members>
8
+ </doc>
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: 2c5b642613e35f74b68879fb8da82621
3
+ TextScriptImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
package/Editor.meta ADDED
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 4635e7e91bdc424b919d622e680bf2f1
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,282 @@
1
+ package com.nexus.cross.capture.unity
2
+
3
+ import android.media.Image
4
+ import android.media.MediaCodec
5
+ import android.media.MediaCodecInfo
6
+ import android.media.MediaFormat
7
+ import android.media.MediaMuxer
8
+ import android.os.Handler
9
+ import android.os.HandlerThread
10
+ import android.util.Log
11
+ import java.util.concurrent.CountDownLatch
12
+ import java.util.concurrent.TimeUnit
13
+
14
+ /**
15
+ * ONE Capture POC native bridge: H.264-encodes RGBA frames pushed from Unity into an MP4 file.
16
+ *
17
+ * Uses MediaCodec's ByteBuffer input path (RGBA->YUV420 conversion done here, written directly
18
+ * into the encoder's real Image planes via getInputImage()) rather than a Surface-backed GPU
19
+ * encode path, since this repo has no native EGL/GL Unity plugin to blit into an encoder Surface.
20
+ * All encode/mux work runs on a dedicated HandlerThread so it never blocks Unity's main/render
21
+ * thread.
22
+ *
23
+ * Frame ordering/sync is the caller's responsibility: presentationTimeUs must be derived from
24
+ * the same clock used to timestamp the touch input log on the Unity side.
25
+ */
26
+ object CROSSxCaptureBridge {
27
+
28
+ private const val TAG = "CROSSxCaptureBridge"
29
+ private const val MIME_TYPE = "video/avc"
30
+ private const val BIT_RATE = 8_000_000
31
+ private const val I_FRAME_INTERVAL = 2
32
+
33
+ private var encoder: MediaCodec? = null
34
+ private var muxer: MediaMuxer? = null
35
+ private var videoTrackIndex = -1
36
+ private var muxerStarted = false
37
+ private var thread: HandlerThread? = null
38
+ private var handler: Handler? = null
39
+ private var width = 0
40
+ private var height = 0
41
+ private var outputPath: String? = null
42
+
43
+ @JvmStatic
44
+ @Synchronized
45
+ fun startSession(sessionId: String, width: Int, height: Int, frameRate: Int, outputMp4Path: String): Boolean {
46
+ return try {
47
+ this.width = width
48
+ this.height = height
49
+ this.outputPath = outputMp4Path
50
+
51
+ val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
52
+ setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
53
+ setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
54
+ setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
55
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
56
+ }
57
+
58
+ encoder = MediaCodec.createEncoderByType(MIME_TYPE).apply {
59
+ configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
60
+ start()
61
+ }
62
+ muxer = MediaMuxer(outputMp4Path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
63
+ muxerStarted = false
64
+ videoTrackIndex = -1
65
+
66
+ thread = HandlerThread("CROSSxCaptureEncoder").also { it.start() }
67
+ handler = Handler(thread!!.looper)
68
+
69
+ Log.i(TAG, "startSession($sessionId, ${width}x$height@$frameRate) -> $outputMp4Path")
70
+ true
71
+ } catch (e: Exception) {
72
+ Log.e(TAG, "startSession failed: ${e.message}", e)
73
+ false
74
+ }
75
+ }
76
+
77
+ /**
78
+ * [flipVertically] compensates for AsyncGPUReadback row order depending on the active
79
+ * graphics API on the Unity side (pass `!SystemInfo.graphicsUVStartsAtTop`). Verify the
80
+ * output orientation on-device; the correct flip direction can vary by GPU/driver.
81
+ */
82
+ @JvmStatic
83
+ fun pushFrame(rgba: ByteArray, frameNumber: Int, presentationTimeUs: Long, flipVertically: Boolean) {
84
+ val h = handler ?: return
85
+ h.post {
86
+ try {
87
+ encodeFrame(rgba, presentationTimeUs, flipVertically)
88
+ } catch (e: Exception) {
89
+ Log.e(TAG, "pushFrame($frameNumber) failed: ${e.message}", e)
90
+ }
91
+ }
92
+ }
93
+
94
+ @JvmStatic
95
+ @Synchronized
96
+ fun stopSession(): String? {
97
+ val h = handler
98
+ val latch = CountDownLatch(1)
99
+ if (h != null) {
100
+ h.post {
101
+ try {
102
+ drainEncoder(endOfStream = true)
103
+ } catch (e: Exception) {
104
+ Log.e(TAG, "drainEncoder(EOS) failed: ${e.message}", e)
105
+ } finally {
106
+ latch.countDown()
107
+ }
108
+ }
109
+ } else {
110
+ latch.countDown()
111
+ }
112
+ latch.await(2, TimeUnit.SECONDS)
113
+
114
+ val finishedPath = outputPath
115
+ try {
116
+ encoder?.stop()
117
+ encoder?.release()
118
+ if (muxerStarted) {
119
+ muxer?.stop()
120
+ }
121
+ muxer?.release()
122
+ } catch (e: Exception) {
123
+ Log.e(TAG, "stopSession release failed: ${e.message}", e)
124
+ } finally {
125
+ encoder = null
126
+ muxer = null
127
+ muxerStarted = false
128
+ videoTrackIndex = -1
129
+ thread?.quitSafely()
130
+ thread = null
131
+ handler = null
132
+ outputPath = null
133
+ }
134
+
135
+ Log.i(TAG, "stopSession -> $finishedPath")
136
+ return finishedPath
137
+ }
138
+
139
+ private fun encodeFrame(rgba: ByteArray, presentationTimeUs: Long, flipVertically: Boolean) {
140
+ val codec = encoder ?: return
141
+ val frameSize = width * height + (width * height) / 2
142
+
143
+ val inputBufferId = codec.dequeueInputBuffer(10_000)
144
+ if (inputBufferId >= 0) {
145
+ // COLOR_FormatYUV420Flexible does NOT guarantee a packed I420 (planar) memory layout —
146
+ // many hardware encoders back it with NV12/NV21 (semi-planar, interleaved U/V) instead.
147
+ // Writing a hand-rolled packed I420 buffer directly into that memory scrambles chroma
148
+ // and produces solid-color banding instead of an image. getInputImage() exposes the
149
+ // encoder's *actual* per-plane rowStride/pixelStride so we write correctly regardless
150
+ // of which physical layout this device's encoder uses.
151
+ val image = codec.getInputImage(inputBufferId)
152
+ if (image != null) {
153
+ writeRgbaIntoImagePlanes(image, rgba, width, height, flipVertically)
154
+ codec.queueInputBuffer(inputBufferId, 0, frameSize, presentationTimeUs, 0)
155
+ } else {
156
+ // Fallback for the rare device where getInputImage() returns null.
157
+ val yuv = rgbaToI420(rgba, width, height, flipVertically)
158
+ val inputBuffer = codec.getInputBuffer(inputBufferId)
159
+ inputBuffer?.clear()
160
+ inputBuffer?.put(yuv)
161
+ codec.queueInputBuffer(inputBufferId, 0, yuv.size, presentationTimeUs, 0)
162
+ }
163
+ } else {
164
+ Log.w(TAG, "No input buffer available, dropping frame at $presentationTimeUs")
165
+ }
166
+
167
+ drainEncoder(endOfStream = false)
168
+ }
169
+
170
+ /** Writes RGBA source pixels into the encoder's real Y/U/V planes, honoring each plane's own rowStride/pixelStride. */
171
+ private fun writeRgbaIntoImagePlanes(image: Image, rgba: ByteArray, width: Int, height: Int, flipVertically: Boolean) {
172
+ val planes = image.planes
173
+ val yPlane = planes[0]
174
+ val uPlane = planes[1]
175
+ val vPlane = planes[2]
176
+ val yBuffer = yPlane.buffer
177
+ val uBuffer = uPlane.buffer
178
+ val vBuffer = vPlane.buffer
179
+ val yRowStride = yPlane.rowStride
180
+ val uRowStride = uPlane.rowStride
181
+ val vRowStride = vPlane.rowStride
182
+ val uPixelStride = uPlane.pixelStride
183
+ val vPixelStride = vPlane.pixelStride
184
+
185
+ for (j in 0 until height) {
186
+ val srcRow = if (flipVertically) height - 1 - j else j
187
+ var rgbaIndex = srcRow * width * 4
188
+ val yRowStart = j * yRowStride
189
+ val chromaRowStart = (j / 2)
190
+ for (i in 0 until width) {
191
+ val r = rgba[rgbaIndex].toInt() and 0xFF
192
+ val g = rgba[rgbaIndex + 1].toInt() and 0xFF
193
+ val b = rgba[rgbaIndex + 2].toInt() and 0xFF
194
+ rgbaIndex += 4
195
+
196
+ val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
197
+ yBuffer.put(yRowStart + i, y.coerceIn(0, 255).toByte())
198
+
199
+ if (j % 2 == 0 && i % 2 == 0) {
200
+ val u = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
201
+ val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
202
+ val chromaCol = i / 2
203
+ uBuffer.put(chromaRowStart * uRowStride + chromaCol * uPixelStride, u.coerceIn(0, 255).toByte())
204
+ vBuffer.put(chromaRowStart * vRowStride + chromaCol * vPixelStride, v.coerceIn(0, 255).toByte())
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ private fun drainEncoder(endOfStream: Boolean) {
211
+ val codec = encoder ?: return
212
+
213
+ if (endOfStream) {
214
+ val inputBufferId = codec.dequeueInputBuffer(10_000)
215
+ if (inputBufferId >= 0) {
216
+ codec.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
217
+ }
218
+ }
219
+
220
+ val bufferInfo = MediaCodec.BufferInfo()
221
+ while (true) {
222
+ val outputBufferId = codec.dequeueOutputBuffer(bufferInfo, 10_000)
223
+ when {
224
+ outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
225
+ val m = muxer ?: return
226
+ videoTrackIndex = m.addTrack(codec.outputFormat)
227
+ m.start()
228
+ muxerStarted = true
229
+ }
230
+ outputBufferId >= 0 -> {
231
+ val outputBuffer = codec.getOutputBuffer(outputBufferId)
232
+ if (outputBuffer != null && bufferInfo.size > 0 && muxerStarted) {
233
+ outputBuffer.position(bufferInfo.offset)
234
+ outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
235
+ muxer?.writeSampleData(videoTrackIndex, outputBuffer, bufferInfo)
236
+ }
237
+ codec.releaseOutputBuffer(outputBufferId, false)
238
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
239
+ }
240
+ outputBufferId == MediaCodec.INFO_TRY_AGAIN_LATER -> {
241
+ if (endOfStream) continue else break
242
+ }
243
+ else -> break
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Packed I420 (planar) fallback, used only when getInputImage() is unavailable on a given
250
+ * device. Assumes the encoder's input buffer is laid out as tightly-packed planar I420,
251
+ * which is not guaranteed — prefer writeRgbaIntoImagePlanes() whenever possible.
252
+ */
253
+ private fun rgbaToI420(rgba: ByteArray, width: Int, height: Int, flipVertically: Boolean): ByteArray {
254
+ val frameSize = width * height
255
+ val yuv = ByteArray(frameSize + frameSize / 2)
256
+ var yIndex = 0
257
+ var uIndex = frameSize
258
+ var vIndex = frameSize + frameSize / 4
259
+
260
+ for (j in 0 until height) {
261
+ val srcRow = if (flipVertically) height - 1 - j else j
262
+ var rgbaIndex = srcRow * width * 4
263
+ for (i in 0 until width) {
264
+ val r = rgba[rgbaIndex].toInt() and 0xFF
265
+ val g = rgba[rgbaIndex + 1].toInt() and 0xFF
266
+ val b = rgba[rgbaIndex + 2].toInt() and 0xFF
267
+ rgbaIndex += 4
268
+
269
+ val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
270
+ yuv[yIndex++] = y.coerceIn(0, 255).toByte()
271
+
272
+ if (j % 2 == 0 && i % 2 == 0) {
273
+ val u = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
274
+ val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
275
+ yuv[uIndex++] = u.coerceIn(0, 255).toByte()
276
+ yuv[vIndex++] = v.coerceIn(0, 255).toByte()
277
+ }
278
+ }
279
+ }
280
+ return yuv
281
+ }
282
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 64847a69ec29483199f960d1f376dcb3
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: bb258fa089c94080bbebfec8e04c0d68
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 7715b99831554960b23c78064349f91f
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 43ebf0ccabf3493f97365eaad911fa61
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 184f5a9325de44b9b5a9f7915ccc4116
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 5c92046a147f4a9e8456da5ceb654f25
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: fa7bc698173044e08309c57593eabb75
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
package/Plugins.meta ADDED
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 0ba94be989d64b7792e053644f6a6da0
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,27 @@
1
+ fileFormatVersion: 2
2
+ guid: 928d401431a55fdbbcfb4642d1bb01e7
3
+ PluginImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ iconMap: {}
7
+ executionOrder: {}
8
+ defineConstraints: []
9
+ isPreloaded: 0
10
+ isOverridable: 0
11
+ isExplicitlyReferenced: 0
12
+ validateReferences: 1
13
+ platformData:
14
+ - first:
15
+ Any:
16
+ second:
17
+ enabled: 1
18
+ settings: {}
19
+ - first:
20
+ Editor: Editor
21
+ second:
22
+ enabled: 0
23
+ settings:
24
+ DefaultValueInitialized: true
25
+ userData:
26
+ assetBundleName:
27
+ assetBundleVariant:
@@ -0,0 +1,325 @@
1
+ <?xml version="1.0"?>
2
+ <doc>
3
+ <assembly>
4
+ <name>CROSSx.Capture.Sdk.Unity</name>
5
+ </assembly>
6
+ <members>
7
+ <member name="T:CROSSx.Capture.Sdk.Unity.Adapters.AndroidCaptureAdapter">
8
+ <summary>
9
+ Android: MediaCodec/MediaMuxer 기반 CROSSxCaptureBridge를 AndroidJavaClass로 호출한다.
10
+ </summary>
11
+ </member>
12
+ <member name="T:CROSSx.Capture.Sdk.Unity.Adapters.NullCaptureAdapter">
13
+ <summary>
14
+ Editor/비-Android 플랫폼용 스텁. 실제 비디오 인코딩은 하지 않고 세션 흐름만 통과시켜,
15
+ 프레임/터치 동기화 로직을 인코더 없이도 크래시 없이 확인할 수 있게 한다.
16
+ </summary>
17
+ </member>
18
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Ports.ICaptureAdapter">
19
+ <summary>
20
+ 플랫폼별 네이티브 캡처 구현이 따르는 포트. 화면 인코딩 세션의 시작/프레임 전달/종료를 정의한다.
21
+ </summary>
22
+ </member>
23
+ <member name="P:CROSSx.Capture.Sdk.Unity.Core.Ports.ICaptureAdapter.IsSupported">
24
+ <summary>이 어댑터가 실제 비디오 인코딩을 지원하는 플랫폼에서 실행 중인지 여부.</summary>
25
+ </member>
26
+ <member name="M:CROSSx.Capture.Sdk.Unity.Core.Ports.ICaptureAdapter.PushFrame(System.Byte[],System.Int32,System.Int64,System.Boolean)">
27
+ <summary>
28
+ RGBA32 프레임 한 장을 인코더에 전달한다. flipVertically는 AsyncGPUReadback의 행 순서를
29
+ 보정하기 위한 값으로, 호출부에서 SystemInfo.graphicsUVStartsAtTop 기반으로 계산해 전달한다.
30
+ </summary>
31
+ </member>
32
+ <member name="M:CROSSx.Capture.Sdk.Unity.Core.Ports.ICaptureAdapter.StopSession">
33
+ <summary>세션을 종료하고 최종 MP4 경로를 반환한다. 지원하지 않는 플랫폼이면 null.</summary>
34
+ </member>
35
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Types.CaptureConfig">
36
+ <summary>
37
+ ONE Capture 세션 설정. 화면 캡처 자체는 항상 디바이스의 실제 해상도로 이뤄지지만
38
+ (ScreenCapture.CaptureScreenshotIntoRenderTexture 제약), 읽어들여 인코딩하는 크기는
39
+ MaxHeight로 다운스케일한다 — 브리프 기본값(1080p)이자 성능 확보를 위한 값.
40
+ 네이티브 해상도가 이보다 작은 기기에서는 업스케일하지 않는다.
41
+ </summary>
42
+ </member>
43
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Types.ActionLogEntry">
44
+ <summary>
45
+ 버튼/기능 사용 히스토리 한 건. UI Toolkit의 ClickEvent가 실제로 발생한 그 순간 바로
46
+ 기록하므로, 좌표 매칭 없이 100% 확정적이다(버튼에 이름/텍스트가 있는 경우에 한해서 —
47
+ 이름이 없으면 애초에 라벨을 알 수 없어 기록되지 않는다).
48
+ </summary>
49
+ <remarks>
50
+ <c>label</c>은 버튼에 표시되는 텍스트(현지화된 문자열)이고, <c>handler</c>는 그 버튼의
51
+ <c>clicked</c> 이벤트에 실제로 바인딩된 메서드 이름("SelectorType.MethodName" 형식,
52
+ 정적 메서드면 타입 접두사 없이 메서드명만)이다. 람다/익명 메서드로 바인딩된 경우 컴파일러가
53
+ 생성한 이름(예: "&lt;BuildUi&gt;b__42_3")이 그대로 나올 수 있어 사람이 읽기엔 label보다
54
+ 못할 수 있다 — 그래서 label을 handler로 대체하지 않고 항상 둘 다 기록한다. 핸들러를 못
55
+ 찾으면(바인딩된 델리게이트가 없으면) handler는 null이고 label만 채워진다.
56
+ </remarks>
57
+ </member>
58
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Types.InputLog">
59
+ <summary>
60
+ 세션 종료 시 input_log_{sessionId}.json으로 그대로 직렬화되는 문서. 원시 터치 좌표(touches)는
61
+ 담지 않는다 — 애초에 이 기능의 목표가 "어떤 버튼/기능을 썼는지"(퍼널)이고, 그 목표에는
62
+ actions만으로 충분하며 좌표 스트림은 최초 브리프에서 넘어온 미사용 잔재였다. 프레임 번호는
63
+ 터치 샘플링과 무관하게(영상 캡처 틱에서) 독립적으로 매겨지므로 이 필드를 제거해도 actions의
64
+ 프레임 태깅에는 영향이 없다.
65
+ </summary>
66
+ </member>
67
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Types.CaptureSessionResult">
68
+ <summary>
69
+ 세션 종료 시 반환되는 결과. 성공/실패 여부와 산출물 경로, 진단 정보를 담는다.
70
+ </summary>
71
+ </member>
72
+ <member name="T:CROSSx.Capture.Sdk.Unity.Core.Utils.CaptureLogger">
73
+ <summary>
74
+ Internal logger for the Capture SDK.
75
+ </summary>
76
+ </member>
77
+ <member name="T:CROSSx.Capture.Sdk.Unity.FrameSync.FrameCaptureController">
78
+ <summary>
79
+ 대상 카메라의 GameObject에 부착되어 화면과 터치를 같은 프레임 번호/타임스탬프로 태깅해
80
+ 캡처한다. 캡처 자체는 WaitForEndOfFrame 코루틴에서 수행한다 — OnPostRender는 그 카메라의
81
+ 렌더링 직후에만 호출되는데, UI Toolkit의 "Screen Space - Overlay" 패널처럼 모든 카메라
82
+ 렌더링이 끝난 뒤 별도로 화면에 합성되는 콘텐츠는 그 시점에 아직 그려지지 않아 놓친다
83
+ (카메라 배경색만 찍히던 원인). WaitForEndOfFrame은 그 프레임의 렌더링과 UI 합성이 전부
84
+ 끝난, 화면이 실제로 표시되기 직전 시점에 실행되므로 화면에 보이는 그대로 캡처된다.
85
+ AsyncGPUReadback은 2~3프레임 늦게 완료되므로, 프레임 번호와 presentationTimeUs를 함께
86
+ 넘겨 인코더 쪽(pushFrame)에서 사후 매칭한다.
87
+ </summary>
88
+ </member>
89
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.FrameCaptureController.Update">
90
+ <summary>
91
+ 클릭 라벨 트래커에 매 프레임 최신 프레임 번호/타임스탬프를 태깅해준다 — 비디오 캡처
92
+ 스로틀(30fps 등)과 무관하게 엔진의 매 프레임마다 갱신해, 클릭이 실제로 발생한 순간에
93
+ 가장 가까운 값을 쓴다.
94
+ </summary>
95
+ </member>
96
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.FrameCaptureController.ComputeEncodeSize(System.Int32,System.Int32,System.Int32)">
97
+ <summary>가로세로 비율을 유지한 채 maxHeight로 다운스케일한다. 원본이 더 작으면 업스케일하지 않는다.</summary>
98
+ </member>
99
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.FrameCaptureController.SaveInputLog">
100
+ <summary>
101
+ input_log_{sessionId}.json을 세션 디렉터리에 저장하고 전체 경로를 반환한다. UI
102
+ Toolkit(_clickTracker)과 uGUI(_uguiClickTracker) 두 트래커의 액션을 하나의
103
+ actions 배열로 합친다 — 어느 UI 시스템으로 만들어진 버튼이든 소비자 입장에서는
104
+ 구분할 필요 없는 동일한 ActionLogEntry 스키마다.
105
+ </summary>
106
+ </member>
107
+ <member name="T:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker">
108
+ <summary>
109
+ uGUI(Canvas + UnityEngine.UI.Button) 클릭을 dApp 코드 수정 없이 전역으로 추적해,
110
+ UIClickLabelTracker(UI Toolkit 전용)와 동일한 ActionLogEntry 스키마로 기록한다.
111
+
112
+ ## 왜 이 클래스가 필요한가
113
+ 이 SDK가 실제로 캡처하려는 대상 게임들 중 UI Toolkit보다 uGUI를 쓰는 비율이 훨씬 높다
114
+ (UI Toolkit의 런타임 UI 지원은 비교적 최근이고, 대부분의 기존/에셋스토어 게임은 여전히
115
+ Canvas+Button 기반이다). UIClickLabelTracker는 UIDocument의 rootVisualElement에서만
116
+ ClickEvent를 듣기 때문에 uGUI 버튼은 애초에 볼 수 있는 지점이 아니다 — 화면 캡처는
117
+ 정상 동작해도 액션 로그가 계속 비어 있게 된다. 이 클래스가 그 구조적 사각지대를 메운다.
118
+
119
+ ## 왜 UnityEngine.UI에 대한 컴파일 타임 참조가 전혀 없는가 (중요)
120
+ UnityEngine.UI.Button/Text/Graphic은 com.unity.ugui라는 별도 빌트인 패키지가 있어야만
121
+ 존재한다(이 머신의 실제 Unity 6000.2.13f1 설치를 확인: BuiltInPackages/com.unity.ugui,
122
+ UnityEngine.UI.dll이 그 패키지의 Runtime/UGUI/UnityEngine.UI.asmdef 산출물). 이 SDK의
123
+ 샘플 프로젝트조차 com.unity.ugui가 없다(com.unity.modules.ui만 있음 — UI Toolkit/IMGUI용
124
+ 빌트인 모듈이며 Button/Text는 별도 패키지 소관). 만약 이 클래스가 `using UnityEngine.UI;`로
125
+ Button을 직접 참조했다면, com.unity.ugui가 없는 모든 프로젝트에서 캡처 SDK 패키지
126
+ 전체가 컴파일 실패한다 — asmdef의 옵셔널 참조(versionDefines) 메커니즘은 실제 Unity
127
+ 에디터의 asmdef 리졸버가 있어야 검증 가능한데, 이 환경에는 그게 없어 확신 없이 걸기엔
128
+ 리스크가 너무 크다. 그래서 Button/Text/Graphic은 전부 런타임 리플렉션으로만 다루고,
129
+ com.unity.ugui가 없으면 이 클래스는 조용히 아무 것도 하지 않는다(IsSupported=false) —
130
+ 패키지 자체는 어떤 프로젝트에서든 항상 컴파일된다.
131
+
132
+ 반대로 UnityEngine.Events.UnityEvent/UnityEventBase/UnityAction은 UnityEngine.CoreModule
133
+ 소속임을 실제 어셈블리로 직접 확인했다(Animation Event 등 UI와 무관한 곳에서도 쓰이는
134
+ 범용 메커니즘이라 ugui 없이도 항상 존재) — 그래서 이 부분은 평범한 컴파일 타임 참조로
135
+ 다루고, Button 자체를 찾는 지점만 리플렉션을 쓴다.
136
+
137
+ ## 어떻게 매 클릭을 확정적으로 잡는가
138
+ UI Toolkit처럼 "루트에 한 번 등록하면 전부 잡히는" 캡처 단계 트릭이 uGUI에는 없다 —
139
+ 클릭은 EventSystem이 레이캐스트한 대상에 직접 IPointerClickHandler.OnPointerClick을
140
+ 호출하는 구조라, 그 지점을 가로챌 전역 후킹 지점이 프레임워크에 없다(하니모니 같은 IL
141
+ 패칭 라이브러리 없이는). 그래서 씬에 존재하는 모든 Button 인스턴스를 찾아 각자의
142
+ `onClick`(UnityEvent)에 리스너를 하나씩 추가하는 방식을 쓴다 — dApp 코드는 전혀 안
143
+ 건드리지만, "이미 찾은 버튼"에만 붙는다는 한계가 있다. 세션 시작 후 새로 생성되는
144
+ 버튼을 놓치지 않기 위해 RescanIntervalFrames마다 재스캔해 아직 후킹 안 된 새 버튼에도
145
+ 리스너를 추가한다(최대 그 프레임 수만큼 지연 — UI Toolkit의 emit 패치처럼 즉시/영구
146
+ 보장은 아니라는 걸 명시적으로 남긴다).
147
+
148
+ `Button.onClick.AddListener`로 붙인 우리 자신의 리스너가 실행되는 시점은 uGUI 자체가
149
+ 이미 "포인터가 이 버튼 위에서 내려갔다가 크게 벗어나지 않고 같은 버튼 위에서 올라왔는지"
150
+ 확인을 마친 뒤다(PointerInputModule의 down/up 대상 일치 검사) — Unity의 EventSystem이
151
+ 이미 해주는 일이라 UI Toolkit의 ClickEvent와 동일한 신뢰도의 "진짜 클릭" 신호를 그대로
152
+ 얻는다. 즉 이름 기반 폴백이나 좌표/거리 임계값 같은 휴리스틱이 이 클래스에도 전혀
153
+ 필요 없다 — UIClickLabelTracker와 같은 이유로, 같은 원칙을 유지한다.
154
+
155
+ ## label / handler
156
+ label은 버튼의 자식(자기 자신 포함) 중 `text`라는 public string 속성을 가진 Graphic
157
+ 컴포넌트(레거시 Text, TMP_Text/TextMeshProUGUI 등)에서 읽는다 — 정확한 타입을 알 필요
158
+ 없이 "text 속성을 가진 그래픽"이라는 덕타이핑으로 레거시/TMP를 동시에 지원하면서도
159
+ TextMeshPro에 대한 컴파일 타임 참조를 추가하지 않는다. handler는 UIClickLabelTracker와
160
+ 동일한 정신으로, onClick에 실제 바인딩된 리스너의 메서드 이름을 두 경로로 읽는다: (1)
161
+ 인스펙터에서 연결한 퍼시스턴트 리스너는 UnityEventBase의 완전히 공개된 API
162
+ (GetPersistentEventCount/GetPersistentMethodName/GetPersistentTarget)로, (2)
163
+ `AddListener`로 코드에서 등록한 런타임 리스너는 UnityEventBase의 내부 구조
164
+ (m_Calls/m_RuntimeCalls/InvokableCall.Delegate)를 리플렉션으로 — 이 내부 구조 자체는
165
+ UnityEngine.CoreModule 소속이라 ugui 유무와 무관하게 항상 존재한다(Unity
166
+ 6000.2.13f1 실제 어셈블리로 확인, 추측 아님).
167
+ </summary>
168
+ </member>
169
+ <member name="P:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.IsSupported">
170
+ <summary>com.unity.ugui가 설치돼 있고 필요한 리플렉션 대상을 전부 찾은 경우에만 true.</summary>
171
+ </member>
172
+ <member name="P:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.Actions">
173
+ <summary>세션 동안 확정적으로 기록된 액션 히스토리. UIClickLabelTracker.Actions와 합쳐져 저장된다.</summary>
174
+ </member>
175
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.AttachAll">
176
+ <summary>씬에 현재 존재하는 모든 Button 인스턴스에 리스너를 등록한다. com.unity.ugui가 없으면 아무 것도 하지 않는다.</summary>
177
+ </member>
178
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.Detach">
179
+ <summary>등록한 모든 리스너를 해제한다. 세션 종료 시 호출한다.</summary>
180
+ </member>
181
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.UpdateFrameContext(System.Int32,System.Int64)">
182
+ <summary>
183
+ FrameCaptureController.Update()가 매 프레임 호출해, 클릭 시점에 태깅할 현재 프레임/시각을
184
+ 갱신하는 동시에 RescanIntervalFrames마다 새로 생성된 버튼을 재스캔해 후킹한다.
185
+ </summary>
186
+ </member>
187
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.ResolveLabel(UnityEngine.Component)">
188
+ <summary>
189
+ 버튼 자신(및 자식)에서 public string "text" 속성을 가진 Graphic 컴포넌트를 찾아 그 값을
190
+ 쓴다 — 레거시 Text와 TMP_Text/TextMeshProUGUI를 정확한 타입을 몰라도 동시에 지원하는
191
+ 덕타이핑이다. 이름 기반 폴백은 없다 — UIClickLabelTracker와 같은 이유로, "라벨을 못
192
+ 찾으면 기록하지 않는다"는 원칙을 그대로 유지한다.
193
+ </summary>
194
+ </member>
195
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UGUIClickLabelTracker.ResolveHandlerName(UnityEngine.Events.UnityEventBase)">
196
+ <summary>
197
+ onClick에 실제로 바인딩된 리스너의 메서드 이름을 모두 모아 "+"로 합친다. 인스펙터에서
198
+ 연결한 퍼시스턴트 리스너는 완전히 공개된 API로, AddListener로 코드에서 등록한 런타임
199
+ 리스너는 UnityEventBase 내부 구조를 리플렉션으로 읽는다(둘 다 이 파일 상단 doc
200
+ comment에서 검증 근거 설명). 하나도 못 찾으면 null — handler는 보조 정보라 label
201
+ 기록에는 영향 없다.
202
+ </summary>
203
+ </member>
204
+ <member name="T:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker">
205
+ <summary>
206
+ UI Toolkit의 실제 클릭 이벤트를 씬의 모든 UIDocument에 전역으로 후킹해, 사용자가 어떤
207
+ 버튼/기능을 사용했는지 확정적인 히스토리(ActionLogEntry)를 기록한다. dApp 쪽에서 버튼마다
208
+ 코드를 추가할 필요 없이 SDK가 자동으로 붙는다 — Firebase Analytics처럼 개발자가 각
209
+ 호출부에 이벤트를 직접 남길 필요가 없다.
210
+
211
+ 좌표/시간 근접 매칭으로 터치 로그에 라벨을 끼워 넣으려던 이전 방식은 폐기했다 — 서로
212
+ 독립적으로 샘플링되는 두 스트림(Input.touches vs UI Toolkit 클릭 이벤트)을 사후에
213
+ 근사치로 짜맞추는 방식이라, 아무리 임계값을 튜닝해도 인접한 버튼이나 겹치는 타이밍에서는
214
+ 근본적으로 100% 보장이 안 됐다(실측으로 여러 번 확인됨). 대신 ClickEvent가 발생하는
215
+ 바로 그 순간 확정적으로 액션을 기록한다 — 좌표 변환도, 거리 임계값도, 매칭 알고리즘도
216
+ 필요 없다. 버튼에 이름/텍스트가 없으면(=애초에 라벨을 알 수 없음) 기록되지 않을 뿐,
217
+ 이름이 있는 버튼은 예외 없이 정확하다.
218
+
219
+ PointerDownEvent가 아니라 ClickEvent를 쓴다 — PointerDownEvent는 손가락이 처음 닿는
220
+ 순간의 대상만 보므로, 스크롤/드래그를 시작하려고 우연히 버튼 위에 손가락을 댄 경우에도
221
+ (실제로는 그 버튼을 누른 게 아닌데도) 액션으로 잘못 기록되는 문제가 실측에서 확인됐다.
222
+ Button처럼 Clickable 매니퓰레이터를 쓰는 실제 컨트롤은 down→up 사이 이동 거리가 임계값을
223
+ 넘으면 ClickEvent 자체가 발생하지 않아 스크롤 오탐이 걸러진다.
224
+
225
+ 프레임 번호는 FrameCaptureController.Update()가 매 프레임 UpdateFrameContext로 갱신해주는
226
+ "가장 최근에 캡처된 비디오 프레임 번호"를 그대로 쓴다 — 정확히 그 순간의 프레임은 아닐
227
+ 수 있지만(다음 비디오 프레임이 캡처되기 전까지의 범위), 영상과 대조하기엔 충분한
228
+ 해상도다.
229
+
230
+ 알려진 한계:
231
+ - UI Toolkit(UIDocument) 기반 UI만 지원한다. 커스텀 UI(둘 다 아닌 자체 렌더링)는 기록되지
232
+ 않는다. uGUI(Canvas/Button)는 이 클래스가 아니라 별도의 UGUIClickLabelTracker가
233
+ 전담한다 — 두 트래커는 FrameCaptureController가 함께 붙이고, 결과는 같은 actions
234
+ 배열로 합쳐진다.
235
+ - 게임 화면(월드) 자체를 탭한 경우는 UI 클릭 이벤트가 발생하지 않으므로 액션으로 남지 않는다
236
+ — 이건 의도된 동작이다(애초에 "어떤 기능"인지 알 방법이 없다).
237
+ - 세션 시작 시점에 씬에 존재하는 UIDocument만 추적한다. 세션 도중 새로 생성된 UIDocument는 잡지 못한다.
238
+ - 클릭 대상의 조상 중에 텍스트가 있는 Button이 없으면 기록되지 않는다(ResolveButton 참고) —
239
+ ScrollView 콘텐츠 컨테이너나 알림 배경 같은 Button 아닌 VisualElement를 탭했을 때도
240
+ ClickEvent 자체는 발생하는데, 이걸 이름 기반으로 걸러내려던 이전 폴백은 실측에서 노이즈로
241
+ 확인되어 제거했다(스크롤 끝에서 우연히 놓은 손가락이 "ButtonsContainer" 같은 가짜 액션으로
242
+ 잡혔었다). 즉 Button이 아닌 요소는 애초에 액션으로 잡히지 않는다 — dApp이 "눌러서 뭔가
243
+ 하는" 요소를 Button으로 만드는 것이 이 기능의 유일한 전제조건이다.
244
+
245
+ ActionLogEntry.label(버튼 표시 텍스트)에 더해, 그 버튼의 clicked 이벤트에 실제로 바인딩된
246
+ 핸들러 메서드 이름을 handler로 함께 기록한다(ResolveHandlerName 참고 — Unity 6000.2.13f1
247
+ 실제 UnityEngine.UIElementsModule.dll을 리플렉션으로 확인해 Clickable.clicked의 백킹
248
+ 필드 이름이 "clicked"임을 검증했다). label을 handler로 대체하지 않는다 — 람다/익명
249
+ 메서드로 바인딩된 버튼은 handler가 "&lt;BuildUi&gt;b__42_3" 같은 컴파일러 생성 이름이 되어
250
+ label보다 덜 유용할 수 있기 때문이다. handler를 못 찾으면(바인딩된 델리게이트가 없거나
251
+ 리플렉션 실패) null이며, 이 경우에도 label 기반 기록 자체는 그대로 유지된다.
252
+ </summary>
253
+ </member>
254
+ <member name="P:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.Actions">
255
+ <summary>세션 동안 확정적으로 기록된 액션 히스토리. Save() 시점에 그대로 직렬화한다.</summary>
256
+ </member>
257
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.AttachAll">
258
+ <summary>씬에 현재 존재하는 모든 UIDocument의 루트에 Click 리스너를 등록한다.</summary>
259
+ </member>
260
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.Detach">
261
+ <summary>등록한 모든 리스너를 해제한다. 세션 종료 시 호출한다.</summary>
262
+ </member>
263
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.UpdateFrameContext(System.Int32,System.Int64)">
264
+ <summary>FrameCaptureController.Update()가 매 프레임 호출해, 클릭 시점에 태깅할 현재 프레임/시각을 갱신한다.</summary>
265
+ </member>
266
+ <member name="F:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.ClickableClickedField">
267
+ <summary>
268
+ UI Toolkit의 <c>Clickable</c> 매니퓰레이터가 <c>clicked</c> 이벤트를 평범한
269
+ field-like event(<c>public event Action clicked;</c>)로 선언하기 때문에, 백킹
270
+ 필드 이름이 이벤트 이름과 정확히 같다("clicked") — Unity 6000.2.13f1의 실제
271
+ UnityEngine.UIElementsModule.dll을 리플렉션으로 직접 확인해 검증했다(추측 아님).
272
+ </summary>
273
+ </member>
274
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.ResolveButton(UnityEngine.UIElements.VisualElement)">
275
+ <summary>
276
+ 클릭 이벤트의 실제 대상부터 부모로 거슬러 올라가며 텍스트가 있는 Button을 찾는다.
277
+ 이름 기반 폴백(가장 가까운 이름 있는 조상)은 쓰지 않는다 — ScrollView 콘텐츠
278
+ 컨테이너나 알림 배경처럼 Button이 아닌 VisualElement도 손가락이 크게 움직이지 않으면
279
+ ClickEvent가 발생하는데(실측으로 확인됨: "ButtonsContainer"/"NotificationContainer"
280
+ 같은 라벨이 실제로는 스크롤/배경 탭에서 나왔었다), 이걸 이름으로 걸러내려면 화면이
281
+ 늘어날 때마다 새로운 컨테이너 이름을 계속 블랙리스트에 추가해야 해서 좌표 매칭 때와
282
+ 같은 "튜닝해야 하는 휴리스틱" 문제로 되돌아간다. Button 텍스트만 확정적으로
283
+ 신뢰한다 — Button이 아닌 요소를 액션으로 잡고 싶으면 dApp 쪽에서 해당 요소를
284
+ Button으로 만들면 된다(UI Toolkit 관례상 "눌러서 뭔가 하는" 요소는 어차피 Button을
285
+ 쓰는 게 정석).
286
+ </summary>
287
+ </member>
288
+ <member name="M:CROSSx.Capture.Sdk.Unity.FrameSync.UIClickLabelTracker.ResolveHandlerName(UnityEngine.UIElements.Button)">
289
+ <summary>
290
+ button.clickable.clicked에 실제로 바인딩된 델리게이트의 invocation list를 리플렉션으로
291
+ 읽어 핸들러 메서드 이름을 반환한다. 여러 핸들러가 바인딩돼 있으면 "+"로 합친다.
292
+ 바인딩된 델리게이트가 없거나 리플렉션이 실패하면(엔진 버전 차이 등) null을 반환한다 —
293
+ label 기록에는 영향 없다(handler는 어디까지나 부가 정보).
294
+ </summary>
295
+ </member>
296
+ <member name="T:CROSSx.Capture.Sdk.Unity.SDK.CROSSxCaptureSDK">
297
+ <summary>
298
+ CROSSx Capture SDK 공개 API (ONE Capture POC).
299
+ 대상 카메라에 FrameCaptureController를 부착해 OnPostRender 훅으로 화면+터치를 동기화 캡처한다.
300
+ </summary>
301
+ </member>
302
+ <member name="M:CROSSx.Capture.Sdk.Unity.SDK.CROSSxCaptureSDK.StartSession(System.String,UnityEngine.Camera)">
303
+ <summary>
304
+ 지정한 카메라(기본값: Camera.main, 없으면 씬의 임의 카메라)에서 화면+터치 동기화
305
+ 캡처 세션을 시작한다. 산출물은 Application.persistentDataPath/OneCapture/{sessionId}/
306
+ 아래에 저장된다.
307
+ </summary>
308
+ </member>
309
+ <member name="M:CROSSx.Capture.Sdk.Unity.SDK.CROSSxCaptureSDK.StopSession">
310
+ <summary>세션을 종료하고 MP4/JSON 경로가 담긴 결과를 반환한다.</summary>
311
+ </member>
312
+ <member name="M:CROSSx.Capture.Sdk.Unity.SDK.CROSSxCaptureSDK.ResolveDefaultCamera">
313
+ <summary>
314
+ Camera.main은 "MainCamera" 태그가 붙은 카메라만 찾으므로, 태그가 없는 프로젝트를
315
+ 위해 씬의 활성 카메라를 임의로라도 찾아 폴백한다.
316
+ </summary>
317
+ </member>
318
+ <member name="T:CROSSx.Capture.Sdk.Unity.SDK.CROSSxCaptureSDKFactory">
319
+ <summary>
320
+ 플랫폼에 맞는 캡처 어댑터를 선택해 CROSSxCaptureSDK 인스턴스를 만드는 팩토리.
321
+ 실기기 Android에서만 실제 인코딩을 수행하며, 그 외 플랫폼은 NullCaptureAdapter로 대체한다.
322
+ </summary>
323
+ </member>
324
+ </members>
325
+ </doc>
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: be97013b995e54e5b561ce5f2430652b
3
+ TextScriptImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
package/Runtime.meta ADDED
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 23096217f9614e5d9284e4e089e181c2
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "com.crossx.capture.sdk.unity",
3
+ "version": "0.0.0-beta.1",
4
+ "displayName": "CROSSx Capture SDK for Unity",
5
+ "description": "ONE Capture POC: frame-synchronized game screen + touch input capture for Android",
6
+ "unity": "2022.3",
7
+ "author": {
8
+ "name": "Cross Nexus",
9
+ "email": "support@crossnexus.com"
10
+ },
11
+ "keywords": [
12
+ "capture",
13
+ "recording",
14
+ "screen",
15
+ "input",
16
+ "sdk"
17
+ ],
18
+ "dependencies": {
19
+ "com.unity.modules.screencapture": "1.0.0",
20
+ "com.unity.modules.uielements": "1.0.0"
21
+ },
22
+ "license": "MIT"
23
+ }
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: b8811ed190ff413183971556b11ec720
3
+ PackageManifestImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant: