This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: retain camera CVPixelBuffer + throttle GC on iOS (v4.15.1)

Two iOS-side fixes for use-after-free crashes (kima downstream + sample
app on iPad/iPhone 17 Pro) and the resulting cpu_resource warnings.

* CameraController.captureOutput now CFRetains the CVImageBufferRef
synchronously on the camera output queue before dispatch_async, with
a matching CFRelease in the worker's finally block (and an outer
bufferEnqueued guard for the dispatch-failed path). Without this the
K/N closure dereferenced freed memory inside FrameProcessor's first
CFRetain because AVCaptureVideoDataOutput recycles sample buffers
the moment the delegate returns.

* The dispatch_async closure no longer captures any per-frame ObjC
reference. Orientation and mirrored flag are snapshotted into local
primitives, lastCaptureConnection is written directly on the camera
output queue (idempotent — connection is constant for the session),
and analyseBufferForAll is invoked with captureConnection = null
since both overrides are non-null.

* GC.collect() is now called every 10 frames (~3 Hz at 30 fps) instead
of every frame. K/N's auto-GC trigger is too lazy at this allocation
rate so a periodic synchronous collect is still required, but cutting
the frequency 10× clears the sustained CPU advisory iOS 26 was
raising on the camera queue.

+69 -16
+1 -1
README.md
··· 11 11 Import the Compose library 12 12 13 13 ```kotlin 14 - implementation("com.performancecoachlab.posedetection:posedetection-compose:4.13.0") 14 + implementation("com.performancecoachlab.posedetection:posedetection-compose:4.15.1") 15 15 ``` 16 16 17 17 Add camera use to your android manifest
+1 -1
posedetection/build.gradle.kts
··· 4 4 5 5 mavenPublishing { 6 6 publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) 7 - coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.15.6") 7 + coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.15.1") 8 8 9 9 pom { 10 10 name.set("Pose Detection")
+67 -14
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/CameraEngine.kt
··· 853 853 854 854 private val frameProcessor = FrameProcessor(null) 855 855 856 + // Counter used to throttle GC hints. captureOutput fires once per camera 857 + // frame (~30 fps); only the camera output queue mutates this so a plain 858 + // Int is safe. 859 + private var gcHintFrameCounter: Int = 0 860 + 856 861 @OptIn(ExperimentalForeignApi::class, NativeRuntimeApi::class) 857 862 override fun captureOutput( 858 863 output: AVCaptureOutput, 859 864 didOutputSampleBuffer: CMSampleBufferRef?, 860 865 fromConnection: AVCaptureConnection 861 866 ) { 867 + // CMSampleBufferRef / CVImageBufferRef are raw CF pointers; Kotlin/Native 868 + // does not retain them when captured by the dispatch_async closure. 869 + // AVCaptureVideoDataOutput recycles sample buffers from a small fixed 870 + // pool the instant this delegate returns, so without a synchronous 871 + // retain here the worker queue dereferences freed memory inside the 872 + // first CFRetain in FrameProcessor.analyseBufferForAll. Retain on the 873 + // camera output queue, release after the worker is done. 874 + val cvBufRetained: CVImageBufferRef? = 875 + CMSampleBufferGetImageBuffer(didOutputSampleBuffer)?.also { CFRetain(it) } 876 + var bufferEnqueued = false 877 + 878 + // Snapshot every per-frame ObjC value into primitives BEFORE the 879 + // dispatch_async hop. The K/N→ObjC bridge retains any ObjC reference 880 + // that ends up captured in a Kotlin closure, and that closure object 881 + // lingers in the K/N heap until the next GC cycle — which forced us 882 + // to call GC.collect() on every frame to keep AVFoundation's 883 + // pixel-buffer pool moving. With orientation captured as a primitive 884 + // and the connection stashed via direct property write (no closure 885 + // capture), the dispatch_async closure holds no per-frame ObjC ref 886 + // and the collector can run at a sane cadence again. 887 + // 888 + // Direct (unlocked) write is safe here because fromConnection is the 889 + // same AVCaptureConnection instance for the lifetime of the session 890 + // — repeated assignments are idempotent. captureCompositeToPng still 891 + // reads it under lastBufferLock together with the rest of the snapshot. 892 + val targetOrientation = desiredVideoOrientation 893 + val mirroredFlag = isUsingFrontCamera 894 + lastCaptureConnection = fromConnection 895 + 862 896 timeStamp().also { timestamp -> 863 897 runCatching { 864 898 dispatch_async(frameProcessingQueue) { ··· 867 901 // This avoids brief flashes of stale frames from the previous lens. 868 902 if (isSwitchingCamera) return@dispatch_async 869 903 870 - // Important: do NOT set videoOrientation on fromConnection. 871 - // AVCaptureVideoDataOutput auto-rotates delivered buffers 872 - // when videoOrientation is set, which would double-rotate 873 - // against our own EXIF pipeline. We deliberately override 874 - // the orientation downstream via effectiveVideoOrientation 875 - // while letting the raw landscape buffer flow through here. 876 - val targetOrientation = desiredVideoOrientation 877 - 878 904 var detectedSkeleton: Skeleton? = null 879 905 var detectedObjects: List<AnalysisObject> = emptyList() 880 906 881 - val cvBuf = CMSampleBufferGetImageBuffer(didOutputSampleBuffer) 907 + val cvBuf = cvBufRetained 908 + // captureConnection is null because both orientation 909 + // and mirrored overrides are non-null — the connection 910 + // would only be consulted as a fallback, and skipping 911 + // it keeps the dispatch_async closure free of 912 + // per-frame ObjC retains. Important: do NOT set 913 + // videoOrientation on the underlying connection. 914 + // AVCaptureVideoDataOutput auto-rotates delivered 915 + // buffers when videoOrientation is set, which would 916 + // double-rotate against our own EXIF pipeline. 882 917 frameProcessor.analyseBufferForAll( 883 918 cvBuf, 884 919 timestamp, 885 920 preview = cameraPreviewLayer, 886 - captureConnection = fromConnection, 921 + captureConnection = null, 887 922 orientationOverride = targetOrientation, 888 - mirroredOverride = isUsingFrontCamera, 923 + mirroredOverride = mirroredFlag, 889 924 onSkeletonProcessed = { skeleton -> 890 925 detectedSkeleton = skeleton 891 926 }, ··· 895 930 ) 896 931 897 932 // Retain the raw buffer + detections for captureComposite. 933 + // lastCaptureConnection is set synchronously above on 934 + // the camera output queue so we don't need to capture 935 + // fromConnection here. 898 936 dispatch_sync(lastBufferLock) { 899 937 lastBuffer?.also { CFRelease(it) } 900 938 lastBuffer = cvBuf?.also { CFRetain(it) } 901 - lastCaptureConnection = fromConnection 902 939 lastDetectedSkeleton = detectedSkeleton 903 940 lastDetectedObjects = detectedObjects 904 941 } ··· 911 948 width = it.width, 912 949 height = it.height, 913 950 orientationOverride = targetOrientation, 914 - mirroredOverride = isUsingFrontCamera, 951 + mirroredOverride = mirroredFlag, 915 952 ) 916 953 } 917 954 ··· 978 1015 } 979 1016 } catch (_: Exception) { 980 1017 // ignore frame processing errors 1018 + } finally { 1019 + cvBufRetained?.let { CFRelease(it) } 981 1020 } 982 1021 } 1022 + bufferEnqueued = true 983 1023 } 984 1024 } 1025 + // If dispatch_async never ran (e.g. queue rejected the block), release 1026 + // the retain we took on the camera output queue so we don't leak. 1027 + if (!bufferEnqueued) cvBufRetained?.let { CFRelease(it) } 985 1028 986 1029 MemoryManager.updateMemoryStatus() 987 - kotlin.native.runtime.GC.collect() 1030 + // Run a synchronous GC every 10 camera frames (~3 Hz at 30 fps) 1031 + // instead of every frame. K/N's auto-GC trigger is too lazy at this 1032 + // allocation rate (per-frame closures, Skeleton/AnalysisObject lists, 1033 + // Vision callback lambdas all churn heavily) — when we tried 1034 + // GC.schedule() here, detection visibly froze because the K/N heap 1035 + // grew faster than the collector ran. A periodic full collect keeps 1036 + // the heap bounded and cuts the per-frame GC cost ~90%. 1037 + if (++gcHintFrameCounter >= 10) { 1038 + gcHintFrameCounter = 0 1039 + kotlin.native.runtime.GC.collect() 1040 + } 988 1041 } 989 1042 990 1043 /**