This repository has no description
0

Configure Feed

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

release 4.16.0: accurate recording-finalize timestamps + camera callback APIs

- Fix VideoRecordEvent.Finalize duration/start-timestamp (~165ms overshoot)
- Add setOnAnalyzerFrameCallback / setOnRecordingFirstFrameTsCallback / setCurrentRecordingId
- Release MediaMetadataRetriever; iOS camera engine alignment
- Publishable coordinates 4.16.0 (non-SNAPSHOT)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+375 -8
+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.1") 7 + coordinates("com.performancecoachlab.posedetection", "posedetection-compose", "4.16.0") 8 8 9 9 pom { 10 10 name.set("Pose Detection")
+199 -5
posedetection/src/androidMain/kotlin/com.performancecoachlab/posedetection/camera/CameraView.android.kt
··· 104 104 } 105 105 } 106 106 107 + // (CAMERA_FRAME_INTERVAL_MS removed — replaced by V3↔V4 average; see 108 + // VideoRecordEvent.Finalize handler for details.) 109 + 107 110 @OptIn(ExperimentalCamera2Interop::class) 108 111 @Composable 109 112 actual fun CameraView( ··· 122 125 poseFocusMode: PoseFocusMode, 123 126 controller: CameraViewController?, 124 127 onRecordToggled: (Boolean) -> Unit, 128 + onAnalyzerFrame: (Long) -> Unit, 129 + onRecordingFirstFrameTs: (String, Long) -> Unit, 125 130 onVideoSaved: (String, String) -> Unit, 126 131 ) { 132 + // Capture the latest analyzer-frame callback in a state holder so the 133 + // analyzer (running on a background executor) always invokes the most 134 + // recent lambda — recomposition would otherwise leave a stale reference 135 + // baked into the executor's closure. 136 + val onAnalyzerFrameRef = remember { androidx.compose.runtime.mutableStateOf(onAnalyzerFrame) } 137 + onAnalyzerFrameRef.value = onAnalyzerFrame 138 + val onRecordingFirstFrameTsRef = remember { 139 + androidx.compose.runtime.mutableStateOf(onRecordingFirstFrameTs) 140 + } 141 + onRecordingFirstFrameTsRef.value = onRecordingFirstFrameTs 127 142 // Replace per-frame UI bitmap with lightweight analysis state. 128 143 var analysisSize by remember { mutableStateOf<Size?>(null) } 129 144 var latestSkeleton by remember { mutableStateOf<Skeleton?>(null) } ··· 137 152 val lastObjectRunAtMs = remember { AtomicLong(0L) } 138 153 val lastPoseRunAtMs = remember { AtomicLong(0L) } 139 154 val analysisFrameCounter = remember { AtomicLong(0L) } 155 + /** 156 + * Sensor-HAL ms timestamp of the most-recent analyzer frame seen by the 157 + * pipeline. Used at VideoRecordEvent.Start time to capture the encoder's 158 + * approximate first-frame timestamp without waiting for the next analyzer 159 + * fire (which would land 1+ frame-intervals AFTER the encoder's PTS=0, 160 + * leaving a constant offset between dump-time and video-time). 161 + */ 162 + val lastAnalyzerFrameTsMs = remember { AtomicLong(0L) } 163 + /** SENSOR_TIMESTAMP (ns) of the most recent camera capture, updated 164 + * by the Camera2Interop SessionCaptureCallback. */ 165 + val lastCaptureSensorNs = remember { AtomicLong(0L) } 166 + /** Snapshot of the encoder's first-frame sensor_t (ns), captured at 167 + * the FIRST VideoRecordEvent.Status after Start. Computed as 168 + * `lastCaptureSensorNs - recordedDurationNanos` at that moment. 169 + * Status fires ~33ms after Start, when the encoder has written one 170 + * frame and lastCapture ≈ encoder's last (= first) frame, so the 171 + * subtraction is exact (within ≤1 frame interval). 172 + * Using lastCapture at Finalize instead overshoots by ~165ms because 173 + * captures keep arriving after the encoder stops. */ 174 + val encoderFirstSensorNs = remember { AtomicLong(0L) } 175 + /** When non-empty, the analyzer fires onRecordingFirstFrameTs(this id, now) 176 + * on its NEXT frame and clears this state. Set by VideoRecordEvent.Start 177 + * so we capture the first analyzer frame whose sensor timestamp is 178 + * post-recording-start — much more reliable than `lastAnalyzerFrameTsMs` 179 + * at Start time, which can be hundreds of ms stale on slow devices 180 + * whenever the gate.tryEnter() drops frames during pose inference. */ 181 + val pendingFirstFrameRecordingId = remember { 182 + java.util.concurrent.atomic.AtomicReference<String?>(null) 183 + } 184 + /** Legacy V3 holder — still tracked for diagnostic logging, no longer 185 + * used as the actual first-frame estimate. */ 186 + var startEstimateV3 by remember { mutableStateOf(0L) } 140 187 141 188 val options = AccuratePoseDetectorOptions.Builder() 142 189 .setDetectorMode(AccuratePoseDetectorOptions.STREAM_MODE) ··· 249 296 ) { event -> 250 297 when (event) { 251 298 is VideoRecordEvent.Start -> { 299 + // Reset the encoder-first-sensor snapshot. Status 300 + // events fire shortly after; the FIRST one will 301 + // capture it. 302 + encoderFirstSensorNs.set(0L) 252 303 onRecordToggled(true) 304 + // V3 fallback (wall-clock) so the app's AWAITING_ 305 + // FIRST_FRAME slot is filled before motion-end 306 + // captures item.startTimestamp. Finalize replaces 307 + // this with the SENSOR_TIMESTAMP-derived value. 308 + val v3 = lastAnalyzerFrameTsMs.get() 309 + startEstimateV3 = if (v3 > 0L) v3 else 0L 310 + co.touchlab.kermit.Logger.withTag("CamFirstFrame").i { 311 + "Start id=${capturedId.takeLast(8)} v3_fallback=$v3" 312 + } 313 + if (v3 > 0L) { 314 + onRecordingFirstFrameTsRef.value(capturedId, v3) 315 + } 316 + } 317 + is VideoRecordEvent.Status -> { 318 + // Track the MIN of (lastCapNs − recordedDurationNanos) 319 + // across all Status events. This value is constant 320 + // when the encoder buffer depth is constant; when 321 + // the encoder transiently catches up to the camera 322 + // (drains its buffer), it dips to the true encoder 323 + // first-frame sensor_t. The MIN strips off whatever 324 + // buffer depth the encoder happened to have at any 325 + // particular Status sample. 326 + val curLast = lastCaptureSensorNs.get() 327 + val curDurNs = event.recordingStats.recordedDurationNanos 328 + if (curLast > 0L && curDurNs > 0L) { 329 + val candidate = curLast - curDurNs 330 + val prev = encoderFirstSensorNs.get() 331 + if (prev == 0L || candidate < prev) { 332 + encoderFirstSensorNs.set(candidate) 333 + val bootEpochOffset = System.currentTimeMillis() - 334 + android.os.SystemClock.elapsedRealtime() 335 + val tFirstFrame = 336 + candidate / 1_000_000L + bootEpochOffset 337 + onRecordingFirstFrameTsRef.value( 338 + capturedId, tFirstFrame 339 + ) 340 + co.touchlab.kermit.Logger.withTag("CamFirstFrame").i { 341 + "Status-MIN id=${capturedId.takeLast(8)} " + 342 + "lastCap=$curLast durNs=$curDurNs " + 343 + "→ firstSensorNs=$candidate (new MIN)" 344 + } 345 + } 346 + } 253 347 } 254 348 is VideoRecordEvent.Finalize -> { 349 + // The encoder-first-sensor anchor was already 350 + // captured at the FIRST Status event (~33ms after 351 + // Start). Use that snapshot. lastCapture - duration 352 + // at Finalize would overshoot by ~165ms because 353 + // captures keep arriving after the encoder stops. 354 + val durationNs = event.recordingStats.recordedDurationNanos 355 + val durationMs = durationNs / 1_000_000L 356 + val firstSensorNs = encoderFirstSensorNs.get() 357 + val bootEpochOffset = System.currentTimeMillis() - 358 + android.os.SystemClock.elapsedRealtime() 359 + val tFirstFrameFromSensor = if (firstSensorNs > 0L) { 360 + firstSensorNs / 1_000_000L + bootEpochOffset 361 + } else 0L 362 + // Fallback chain when no capture callback arrived 363 + // (shouldn't happen on Android with Camera2Interop). 364 + val mtimeMs = try { 365 + java.io.File(capturedPath).lastModified() 366 + } catch (_: Throwable) { 0L } 367 + val mp4DurationMs = try { 368 + val mmr = android.media.MediaMetadataRetriever() 369 + mmr.setDataSource(capturedPath) 370 + val s = mmr.extractMetadata( 371 + android.media.MediaMetadataRetriever.METADATA_KEY_DURATION 372 + ) 373 + mmr.release() 374 + s?.toLongOrNull() ?: 0L 375 + } catch (_: Throwable) { 0L } 376 + val tFirstFrame = when { 377 + tFirstFrameFromSensor > 0L -> tFirstFrameFromSensor 378 + mtimeMs > 0L && mp4DurationMs > 0L -> 379 + mtimeMs - mp4DurationMs 380 + else -> { 381 + val now = System.currentTimeMillis() 382 + val v3 = startEstimateV3 383 + val v4 = now - durationMs 384 + if (v3 > 0L) (v3 + v4) / 2 else v4 385 + } 386 + } 387 + val lastCapNsAtFinalize = lastCaptureSensorNs.get() 388 + co.touchlab.kermit.Logger.withTag("CamFirstFrame").i { 389 + "Finalize id=${capturedId.takeLast(8)} " + 390 + "snapshotFirstSensorNs=$firstSensorNs " + 391 + "lastCapAtFinalize=$lastCapNsAtFinalize durNs=$durationNs " + 392 + "tFromSensor=$tFirstFrameFromSensor " + 393 + "mtime=$mtimeMs mp4Dur=$mp4DurationMs " + 394 + "→ tFirstFrame=$tFirstFrame " + 395 + "path=${capturedPath.substringAfterLast('/')}" 396 + } 397 + if (tFirstFrame > 0) { 398 + onRecordingFirstFrameTsRef.value(capturedId, tFirstFrame) 399 + } 400 + startEstimateV3 = 0L 401 + pendingFirstFrameRecordingId.set(null) 255 402 onRecordToggled(false) 256 403 onVideoSaved(capturedId, capturedPath) 257 404 currentCxRecording = null ··· 290 437 val preview = Preview.Builder().build().also { 291 438 it.surfaceProvider = previewView.surfaceProvider 292 439 } 293 - val imageAnalysis = ImageAnalysis.Builder() 440 + val imageAnalysisBuilder = ImageAnalysis.Builder() 294 441 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) 295 442 .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) 296 443 .setTargetAspectRatio(AspectRatio.RATIO_4_3) 297 - .build() 444 + // Register a session-wide CaptureCallback via Camera2Interop. It 445 + // fires on every camera capture (= every video frame the encoder 446 + // sees too), giving us TotalCaptureResult.SENSOR_TIMESTAMP — the 447 + // exact sensor-HAL ns of each frame, in the same clock the encoder 448 + // uses for PTS and ImageAnalysis uses for imageInfo.timestamp. 449 + // We snapshot the first such timestamp after VideoRecordEvent.Start 450 + // as the encoder's frame 0 anchor — no wall-clock conversion, no 451 + // analyzer-staleness error. 452 + androidx.camera.camera2.interop.Camera2Interop.Extender(imageAnalysisBuilder) 453 + .setSessionCaptureCallback( 454 + object : android.hardware.camera2.CameraCaptureSession.CaptureCallback() { 455 + override fun onCaptureCompleted( 456 + session: android.hardware.camera2.CameraCaptureSession, 457 + request: android.hardware.camera2.CaptureRequest, 458 + result: android.hardware.camera2.TotalCaptureResult, 459 + ) { 460 + val sensorNs = result.get( 461 + android.hardware.camera2.CaptureResult.SENSOR_TIMESTAMP 462 + ) ?: return 463 + lastCaptureSensorNs.set(sensorNs) 464 + } 465 + } 466 + ) 467 + val imageAnalysis = imageAnalysisBuilder.build() 298 468 .also { analysis -> 299 469 analysis.targetRotation = rotation 300 470 analysis.setAnalyzer(executor) { imageProxy -> ··· 306 476 307 477 // Convert sensor timestamp to epoch time. 308 478 // sensor is REALTIME ns, convert to epoch ms via boot offset. 309 - val sensorMs = imageProxy.imageInfo.timestamp / 1_000_000L 310 - val bootEpochOffset = System.currentTimeMillis() - android.os.SystemClock.elapsedRealtime() 479 + val sensorNs = imageProxy.imageInfo.timestamp 480 + val sensorMs = sensorNs / 1_000_000L 481 + val wallNowMs = System.currentTimeMillis() 482 + val elapsedMs = android.os.SystemClock.elapsedRealtime() 483 + val bootEpochOffset = wallNowMs - elapsedMs 311 484 val now = sensorMs + bootEpochOffset 485 + // Diagnostic: raw camera-clock vs wall clock vs derived `now`. 486 + // Fires every analyzer frame so the log can be sampled to 487 + // verify the `imageInfo.timestamp` clock source matches the 488 + // encoded video's PTS time-base (= sensor wall-clock). 489 + val frameCounter = analysisFrameCounter.incrementAndGet() 490 + if (frameCounter % 30 == 0L) { 491 + co.touchlab.kermit.Logger.withTag("CamFrameClock").i { 492 + "frame#$frameCounter sensorNs=$sensorNs sensorMs=$sensorMs " + 493 + "wallNow=$wallNowMs elapsed=$elapsedMs bootOffset=$bootEpochOffset " + 494 + "now=$now (now−wallNow=${now - wallNowMs}ms)" 495 + } 496 + } 497 + // Fires for every camera frame regardless of whether pose 498 + // or object inference runs this iteration. `now` is sensor- 499 + // HAL aligned so it matches the encoded video's PTS clock. 500 + lastAnalyzerFrameTsMs.set(now) 501 + onAnalyzerFrameRef.value(now) 502 + // (Option A removed — first analyzer frame after Start 503 + // landed ~1s before encoder PTS=0 on Samsung due to 504 + // encoder warmup, making alignment worse.) 312 505 val area = focus 313 506 314 507 // In BOTH mode, run both detectors on every eligible frame. ··· 320 513 // half for ~20% combined-FPS gain — ditched in favor of 321 514 // doubling per-detector coverage, which matters more for 322 515 // ball-near-hoop detection. 323 - analysisFrameCounter.incrementAndGet() 516 + // NOTE: increment moved earlier (with the per-frame log) 517 + // so frameCounter is available in the log line. 324 518 val shouldRunObject = currentDetectMode.doObject() && 325 519 (now - lastObjectRunAtMs.get() >= objectIntervalMs) 326 520 val shouldRunPose = currentDetectMode.doPose() &&
+20
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/camera/CameraView.kt
··· 69 69 poseFocusMode: PoseFocusMode = PoseFocusMode.MASK, 70 70 controller: CameraViewController? = null, 71 71 onRecordToggled: (Boolean) -> Unit = {}, 72 + /** 73 + * Fires for every camera analyzer frame regardless of whether pose or 74 + * object inference ran. The timestamp is sensor-HAL aligned epoch ms. 75 + * Mostly diagnostic — most callers want onRecordingFirstFrameTs. 76 + */ 77 + onAnalyzerFrame: (Long) -> Unit = {}, 78 + /** 79 + * Reports the wall-clock epoch ms of the encoder's first frame (= PTS=0 80 + * of the saved .mp4). Fires multiple times per recording with refining 81 + * accuracy: 82 + * 1. At VideoRecordEvent.Start: the most-recent analyzer-frame time 83 + * seen so far (rough estimate, off by tens of ms). 84 + * 2. At each VideoRecordEvent.Status (~1 Hz during recording): exact 85 + * value computed from `now - recordingStats.recordedDurationNanos`. 86 + * 3. At VideoRecordEvent.Finalize: same formula on the FULL duration — 87 + * most accurate; this is the value to persist. 88 + * The first parameter is the same `recordingId` the caller passed in, 89 + * so concurrent recordings (split-recording) can be disambiguated. 90 + */ 91 + onRecordingFirstFrameTs: (String, Long) -> Unit = { _, _ -> }, 72 92 onVideoSaved: (String, String) -> Unit 73 93 ) 74 94
+144 -2
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/CameraEngine.kt
··· 108 108 import platform.UIKit.UIImageOrientation 109 109 import platform.UIKit.UIImagePNGRepresentation 110 110 import platform.CoreMedia.CMSampleBufferGetImageBuffer 111 + import platform.CoreMedia.CMSampleBufferGetPresentationTimeStamp 111 112 import platform.CoreMedia.CMSampleBufferRef 113 + import platform.CoreMedia.CMTimeGetSeconds 112 114 import platform.Foundation.NSData 113 115 import platform.Foundation.NSError 114 116 import platform.Foundation.NSURL ··· 150 152 class CameraEngine : UIViewController(null, null) { 151 153 val cameraController = CameraController() 152 154 private var onVideoSaved: ((String) -> Unit)? = null 155 + /** Per-frame analyzer callback. Set by setOnAnalyzerFrameCallback(). 156 + * Fires for every camera capture buffer regardless of inference. */ 157 + private var onAnalyzerFrame: ((Long) -> Unit)? = null 153 158 154 159 private var textMeasurer: androidx.compose.ui.text.TextMeasurer? = null 155 160 ··· 330 335 cameraController.onVideoSaved = callback 331 336 } 332 337 338 + fun setOnAnalyzerFrameCallback(callback: (Long) -> Unit) { 339 + onAnalyzerFrame = callback 340 + cameraController.onAnalyzerFrame = callback 341 + } 342 + 343 + fun setOnRecordingFirstFrameTsCallback(callback: (String, Long) -> Unit) { 344 + cameraController.onRecordingFirstFrameTs = callback 345 + } 346 + 347 + /** Mirror of the Android lib's recordingId tracking — the app sets 348 + * this when starting a recording so the lib can fire 349 + * onRecordingFirstFrameTs with the matching id. */ 350 + fun setCurrentRecordingId(id: String?) { 351 + cameraController.currentRecordingId = id 352 + } 353 + 333 354 fun setTextMeasurer(textMeasurer: androidx.compose.ui.text.TextMeasurer) { 334 355 cameraController.textMeasurer = textMeasurer 335 356 } ··· 380 401 var onError: ((CameraException) -> Unit)? = null 381 402 var startTime: Long? = null 382 403 var onVideoSaved: ((String) -> Unit)? = null 404 + /** Per-frame analyzer callback. Mirrors CameraEngine.onAnalyzerFrame — 405 + * CameraEngine.setOnAnalyzerFrameCallback writes through to this field 406 + * so the controller's captureOutput can invoke the lambda directly 407 + * without indirection through the engine. */ 408 + var onAnalyzerFrame: ((Long) -> Unit)? = null 409 + /** (recordingId, encoderFirstFrameWallClockMs) — fires when we have a 410 + * refined estimate of the encoder's PTS=0 wall clock. Set by 411 + * CameraEngine.setOnRecordingFirstFrameTsCallback. */ 412 + var onRecordingFirstFrameTs: ((String, Long) -> Unit)? = null 413 + /** Recording id of the in-flight recording (mirrors what the app passed 414 + * via recordingId). Used so onRecordingFirstFrameTs can be associated 415 + * with the right shot id, mirroring the Android contract. */ 416 + var currentRecordingId: String? = null 417 + /** MIN of (lastFrameWallClockMs − recordedDurationMs) across all per- 418 + * frame captures during the in-flight recording. Reset on 419 + * startRecording, frozen on didFinishRecording. The MIN catches the 420 + * moment the encoder buffer is most-drained, giving us the encoder's 421 + * first-frame wall-clock time exactly (within ≤1 frame). Same approach 422 + * as v23 on Android. */ 423 + var encoderFirstFrameMinWallMs: Long = 0L 424 + /** v25: capture the first AVCaptureVideoDataOutput frame whose PTS is 425 + * ≥ the host time at startRecording call. That frame's pts (in host 426 + * clock) corresponds — within ≤1 sample — to the first frame the 427 + * movieFileOutput will encode to disk, since both outputs share the 428 + * same camera input. Computing wall_T0 from that single PTS + a 429 + * fresh hostToWall offset gives a single shot, no MIN tracking. */ 430 + var recordingStartHostMs: Long = 0L 431 + var firstFrameAfterStartCaptured: Boolean = false 432 + /** Queued next-recording URL during a split. AVCaptureMovieFileOutput 433 + * can't accept startRecordingToOutputFileURL while it's still 434 + * finalising a previous recording (Apple's docs say wait until the 435 + * delegate's didFinishRecordingToOutputFileAtURL fires). Calling 436 + * start synchronously after stop drops every other split silently — 437 + * that's why ~50% of iOS shots had no video file. We now park the 438 + * intended URL here, return it to the caller (so they can associate 439 + * it with the shot id), and actually call start from inside the 440 + * didFinishRecording delegate. */ 441 + var pendingStartURL: platform.Foundation.NSURL? = null 442 + /** Wall-clock at the most recent captureOutput delegate fire — sampled 443 + * alongside movieFileOutput.recordedDuration to compute the MIN. */ 444 + var lastFrameWallClockMs: Long = 0L 383 445 var drawSkeleton: Boolean = true 384 446 var drawObjects: ((List<AnalysisObject>) -> List<DrawableObject>)? = null 385 447 var textMeasurer: androidx.compose.ui.text.TextMeasurer? = null ··· 485 547 } 486 548 487 549 } 550 + // Reset state before the new recording starts. Snapshot host time 551 + // RIGHT BEFORE the startRecording call — the first camera frame whose 552 + // PTS is ≥ this snapshot is, within ≤1 sample, the first frame the 553 + // movie file output will write to disk. 554 + encoderFirstFrameMinWallMs = 0L 555 + recordingStartHostMs = (platform.QuartzCore.CACurrentMediaTime() * 1000.0).toLong() 556 + firstFrameAfterStartCaptured = false 488 557 movieFileOutput?.startRecordingToOutputFileURL(outputURL, this) 489 558 return outputURL.path 490 559 } ··· 494 563 } 495 564 496 565 fun splitRecording(): String? { 497 - movieFileOutput?.stopRecording() 566 + val mfo = movieFileOutput ?: return null 498 567 val outputURL = generateSegmentURL() 499 - movieFileOutput?.startRecordingToOutputFileURL(outputURL, this) 568 + encoderFirstFrameMinWallMs = 0L 569 + recordingStartHostMs = (platform.QuartzCore.CACurrentMediaTime() * 1000.0).toLong() 570 + firstFrameAfterStartCaptured = false 571 + if (mfo.isRecording()) { 572 + // Defer the actual startRecording until the previous recording's 573 + // didFinishRecording delegate fires (see captureOutput 574 + // didFinishRecordingToOutputFileAtURL: below). Returning the 575 + // URL early lets the caller associate it with the shot id 576 + // even though the file won't be created until the previous 577 + // one is fully finalised. 578 + pendingStartURL = outputURL 579 + mfo.stopRecording() 580 + } else { 581 + mfo.startRecordingToOutputFileURL(outputURL, this) 582 + } 500 583 return outputURL.path 501 584 } 502 585 ··· 893 976 val mirroredFlag = isUsingFrontCamera 894 977 lastCaptureConnection = fromConnection 895 978 979 + // Per-frame wall-clock at the moment the capture delegate fires. 980 + // This is the same source the rest of the iOS pipeline already uses; 981 + // the alignment win comes from emitting it via onAnalyzerFrame, which 982 + // lets CoachedSession capture the FIRST frame after a recording 983 + // started (rather than the wall-clock at the onRecordToggled callback, 984 + // which fires before any frame has actually been processed). 896 985 timeStamp().also { timestamp -> 986 + onAnalyzerFrame?.invoke(timestamp) 987 + // Track lastFrameWallClockMs for downstream consumers. 988 + lastFrameWallClockMs = timestamp 989 + // Compute encoder T0 in WALL-CLOCK by mixing two clocks correctly: 990 + // pts_h = CMSampleBufferGetPresentationTimeStamp (host clock, 991 + // = camera-sensor capture time of THIS frame) 992 + // recDur = movieFileOutput.recordedDuration (host-clock-based, 993 + // = pts_h - pts_h_first_frame_in_movie) 994 + // pts_h_first ≈ pts_h - recDur (encoder T0 in host time) 995 + // wall_first = pts_h_first + (wallNow - pts_h) 996 + // = wallNow - (pts_h - pts_h_first) 997 + // = wallNow - recDur + (wallNow - pts_h - delivery_lag) 998 + // The naive `wallNow - recDur` overshoots by `delivery_lag` (the 999 + // 100-150ms gap between camera capture and delegate fire on iOS). 1000 + // Using `pts_h` for the rolling subtrahend, plus a fresh 1001 + // wall↔host offset captured at this same delegate invocation, 1002 + // cancels the delivery lag exactly. MIN over many frames absorbs 1003 + // any sub-frame jitter in either clock. 1004 + // v25 — first-frame-after-startRecording approach. Capture the 1005 + // first camera sample whose PTS is ≥ the host time at the moment 1006 + // startRecording was called. That sample is, within ≤1 frame, 1007 + // the first frame movieFileOutput writes to disk (since both 1008 + // outputs share the same camera input). Compute wall_T0 from 1009 + // that single PTS + a fresh hostToWall offset captured at the 1010 + // same delegate fire — no recordedDuration lag, no MIN needed. 1011 + val mfo = movieFileOutput 1012 + if (mfo != null && mfo.isRecording() && !firstFrameAfterStartCaptured) { 1013 + val ptsMs = (CMTimeGetSeconds( 1014 + CMSampleBufferGetPresentationTimeStamp(didOutputSampleBuffer) 1015 + ) * 1000.0).toLong() 1016 + if (ptsMs >= recordingStartHostMs) { 1017 + val hostNowMs = (platform.QuartzCore.CACurrentMediaTime() * 1000.0).toLong() 1018 + val hostToWallMs = timestamp - hostNowMs 1019 + val wallT0 = ptsMs + hostToWallMs 1020 + encoderFirstFrameMinWallMs = wallT0 1021 + firstFrameAfterStartCaptured = true 1022 + currentRecordingId?.let { id -> 1023 + onRecordingFirstFrameTs?.invoke(id, wallT0) 1024 + } 1025 + } 1026 + } 897 1027 runCatching { 898 1028 dispatch_async(frameProcessingQueue) { 899 1029 try { ··· 1162 1292 error: NSError? 1163 1293 ) { 1164 1294 onVideoSaved?.invoke(didFinishRecordingToOutputFileAtURL.path ?: "") 1295 + // Fire the queued startRecording from the most recent 1296 + // splitRecording call, now that the previous file has fully 1297 + // finalised on disk. 1298 + val queued = pendingStartURL 1299 + if (queued != null) { 1300 + pendingStartURL = null 1301 + // Reset the v25 first-frame trackers for the new recording. 1302 + encoderFirstFrameMinWallMs = 0L 1303 + recordingStartHostMs = (platform.QuartzCore.CACurrentMediaTime() * 1000.0).toLong() 1304 + firstFrameAfterStartCaptured = false 1305 + movieFileOutput?.startRecordingToOutputFileURL(queued, this) 1306 + } 1165 1307 } 1166 1308 1167 1309 // Test-harness / debug helper: composite the last camera frame with the
+11
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/camera/CameraView.ios.kt
··· 40 40 poseFocusMode: PoseFocusMode, 41 41 controller: CameraViewController?, 42 42 onRecordToggled: (Boolean) -> Unit, 43 + onAnalyzerFrame: (Long) -> Unit, 44 + onRecordingFirstFrameTs: (String, Long) -> Unit, 43 45 onVideoSaved: (String, String) -> Unit, 44 46 ) { 45 47 val cameraEngine = remember { mutableStateOf<CameraEngine?>(null) } 48 + val onAnalyzerFrameRef = remember { mutableStateOf(onAnalyzerFrame) } 49 + onAnalyzerFrameRef.value = onAnalyzerFrame 50 + val onRecordingFirstFrameTsRef = remember { mutableStateOf(onRecordingFirstFrameTs) } 51 + onRecordingFirstFrameTsRef.value = onRecordingFirstFrameTs 46 52 val frameListener = remember { FrameRepository() } 47 53 val frameBitmap by frameListener.frameFlow.collectAsState() 48 54 var lastRecordingState by remember { mutableStateOf(false) } ··· 72 78 addCameraViewController(controller) 73 79 addFrameListener(frameListener) 74 80 setOnVideoSavedCallback(recordingDone) 81 + setOnAnalyzerFrameCallback { ts -> onAnalyzerFrameRef.value(ts) } 82 + setOnRecordingFirstFrameTsCallback { id, ts -> 83 + onRecordingFirstFrameTsRef.value(id, ts) 84 + } 85 + setCurrentRecordingId(recordingId) 75 86 setDrawOptions( 76 87 drawSkeleton = drawSkeleton, 77 88 drawObjects = drawObjects,