Determine exact timestamps #2

closed
opened by nateholland.bsky.social targeting master from interview-pr

extract exact timestamps from video and use them when extracting frames

Changed files
+191 -1
posedetection
src
androidMain
kotlin
com
performancecoachlab
posedetection
commonMain
kotlin
com
performancecoachlab
posedetection
recording
iosMain
kotlin
com
performancecoachlab
posedetection
recording
+46
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.android.kt
···
import android.app.Application
import android.content.Context
+
import android.media.MediaExtractor
+
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
···
}
}
+
actual suspend fun listVideoFrameTimestamps(
+
videoPath: String,
+
): List<Long> = withContext(Dispatchers.IO) {
+
val ctx = VideoExtractionContext.get()
+
val uri = videoPath.toUri()
+
val extractor = MediaExtractor()
+
+
val out = ArrayList<Long>()
+
try {
+
extractor.setDataSource(ctx, uri, null)
+
+
// Select video track
+
var videoTrack = -1
+
for (i in 0 until extractor.trackCount) {
+
val format = extractor.getTrackFormat(i)
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
+
if (mime.startsWith("video/")) {
+
videoTrack = i
+
break
+
}
+
}
+
if (videoTrack == -1) return@withContext out
+
+
extractor.selectTrack(videoTrack)
+
+
var idx = 0
+
while (true) {
+
val sampleTimeUs = extractor.sampleTime
+
if (sampleTimeUs < 0) break
+
+
out.add(sampleTimeUs / 1000L) // ms
+
if (out.size >= Int.MAX_VALUE) break
+
idx++
+
+
if (!extractor.advance()) break
+
}
+
} catch (_: Throwable) {
+
// swallow; return what we have
+
} finally {
+
extractor.release()
+
}
+
out
+
}
+
actual object VideoExtractionContext {
private lateinit var application: Application
+4
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.kt
···
videoPath: String, frameTimestamp: Long
): InputFrame?
+
expect suspend fun listVideoFrameTimestamps(
+
videoPath: String,
+
): List<Long>
+
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object VideoExtractionContext
+141 -1
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.ios.kt
···
package com.performancecoachlab.posedetection.recording
+
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
+
import kotlinx.cinterop.ObjCObjectVar
+
import kotlinx.cinterop.alloc
+
import kotlinx.cinterop.memScoped
+
import kotlinx.cinterop.ptr
+
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import platform.AVFoundation.AVAsset
import platform.AVFoundation.AVAssetImageGenerator
+
import platform.AVFoundation.AVAssetReader
+
import platform.AVFoundation.AVAssetReaderTrackOutput
+
import platform.AVFoundation.AVAssetTrack
+
import platform.AVFoundation.AVMediaTypeVideo
import platform.AVFoundation.CMTimeValue
+
import platform.AVFoundation.tracksWithMediaType
import platform.AVFoundation.valueWithCMTime
import platform.CoreMedia.CMTimeMake
import platform.CoreMedia.CMTimeMakeWithSeconds
+
import platform.CoreMedia.CMTimeGetSeconds
+
import platform.CoreMedia.CMSampleBufferGetPresentationTimeStamp
+
import platform.CoreFoundation.CFRelease
+
import platform.Foundation.NSError
import platform.Foundation.NSURL
import platform.Foundation.NSValue
+
import platform.Foundation.NSFileManager
@OptIn(ExperimentalForeignApi::class)
actual suspend fun extractFrame(
···
}
}
+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+
actual suspend fun listVideoFrameTimestamps(
+
videoPath: String,
+
): List<Long> = withContext(Dispatchers.IO) {
+
val out = mutableListOf<Long>()
+
+
try {
+
val resolvedUrl = createUrl(url = videoPath)
+
println("[iOS] listVideoFrameTimestamps using URL: ${'$'}{resolvedUrl.absoluteString}")
+
+
// AVAssetReader requires a local file URL. Bail out early for remote URLs.
+
if (!resolvedUrl.isFileURL()) {
+
println("AVAssetReader requires a local file URL; got: ${'$'}{resolvedUrl.scheme}:// ...")
+
return@withContext out
+
}
+
+
// Verify the file actually exists at path
+
val path = resolvedUrl.path
+
if (path == null || !NSFileManager.defaultManager.fileExistsAtPath(path)) {
+
println("File does not exist at path: ${'$'}path")
+
return@withContext out
+
}
+
+
val asset = AVAsset.assetWithURL(resolvedUrl)
+
+
// Use synchronous approach - get tracks directly using tracksWithMediaType
+
val videoTracks = asset.tracksWithMediaType(AVMediaTypeVideo)
+
+
if (videoTracks.isEmpty()) {
+
println("No video tracks found in video: ${'$'}videoPath")
+
return@withContext out
+
}
+
+
val videoTrack = videoTracks.first() as AVAssetTrack
+
println("Found video track: ${'$'}{videoTrack}")
+
+
memScoped {
+
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
+
val reader = AVAssetReader.assetReaderWithAsset(asset, error = errorPtr.ptr)
+
+
if (reader == null) {
+
val err = errorPtr.value
+
println("Failed to create AVAssetReader: ${'$'}{err?.localizedDescription}")
+
return@withContext out
+
}
+
+
val trackOutput = AVAssetReaderTrackOutput.assetReaderTrackOutputWithTrack(
+
track = videoTrack,
+
outputSettings = null
+
)
+
+
if (!reader.canAddOutput(trackOutput)) {
+
println("Cannot add track output to reader")
+
return@withContext out
+
}
+
+
reader.addOutput(trackOutput)
+
+
if (!reader.startReading()) {
+
println("Failed to start reading: ${'$'}{reader.error?.localizedDescription}")
+
return@withContext out
+
}
+
+
println("Started reading video frames...")
+
var frameCount = 0
+
while (true) {
+
val sampleBuffer = trackOutput.copyNextSampleBuffer()
+
if (sampleBuffer == null) {
+
break
+
}
+
+
val presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+
val timeInSeconds = CMTimeGetSeconds(presentationTime)
+
val timeInMillis = (timeInSeconds * 1000.0).toLong()
+
+
out.add(timeInMillis)
+
frameCount++
+
+
// Release the sample buffer
+
CFRelease(sampleBuffer)
+
+
if (out.size >= Int.MAX_VALUE) break
+
}
+
+
println("Extracted ${'$'}frameCount frame timestamps")
+
}
+
+
} catch (e: Exception) {
+
println("Exception in listVideoFrameTimestamps: ${'$'}{e.message}")
+
e.printStackTrace()
+
}
+
+
println("Returning ${'$'}{out.size} timestamps")
+
out
+
}
+
fun createUrl(url: String): NSURL {
-
return NSURL.fileURLWithPath(url)
+
val trimmed = url.trim()
+
+
// Remote URLs: return as-is
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
+
return requireNotNull(NSURL.URLWithString(trimmed)) { "Invalid http(s) URL: ${'$'}trimmed" }
+
}
+
+
// Well-formed file URLs
+
if (trimmed.startsWith("file://")) {
+
// Normalize to a proper file URL via its path component to avoid odd host parts
+
NSURL.URLWithString(trimmed)?.let { fileUrl ->
+
fileUrl.path?.let { path ->
+
return NSURL.fileURLWithPath(path)
+
}
+
return fileUrl
+
}
+
}
+
+
// Malformed file: prefix (e.g., "file:/var/..."), coerce to path
+
if (trimmed.startsWith("file:")) {
+
var path = trimmed.removePrefix("file:")
+
// Remove duplicate leading slashes
+
while (path.startsWith("//")) path = path.removePrefix("//")
+
if (!path.startsWith("/")) path = "/${'$'}path"
+
return NSURL.fileURLWithPath(path)
+
}
+
+
// Otherwise treat as a filesystem path
+
return NSURL.fileURLWithPath(trimmed)
}
actual object VideoExtractionContext