back interdiff of round #1 and #0

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

files
posedetection
src
androidMain
kotlin
com
performancecoachlab
posedetection
commonMain
kotlin
com
performancecoachlab
posedetection
recording
iosMain
kotlin
com
performancecoachlab
posedetection
recording
REVERTED
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
REVERTED
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
REVERTED
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