···
package com.performancecoachlab.posedetection.recording
3
+
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
5
+
import kotlinx.cinterop.ObjCObjectVar
6
+
import kotlinx.cinterop.alloc
7
+
import kotlinx.cinterop.memScoped
8
+
import kotlinx.cinterop.ptr
9
+
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import platform.AVFoundation.AVAsset
import platform.AVFoundation.AVAssetImageGenerator
15
+
import platform.AVFoundation.AVAssetReader
16
+
import platform.AVFoundation.AVAssetReaderTrackOutput
17
+
import platform.AVFoundation.AVAssetTrack
18
+
import platform.AVFoundation.AVMediaTypeVideo
import platform.AVFoundation.CMTimeValue
20
+
import platform.AVFoundation.tracksWithMediaType
import platform.AVFoundation.valueWithCMTime
import platform.CoreMedia.CMTimeMake
import platform.CoreMedia.CMTimeMakeWithSeconds
24
+
import platform.CoreMedia.CMTimeGetSeconds
25
+
import platform.CoreMedia.CMSampleBufferGetPresentationTimeStamp
26
+
import platform.CoreFoundation.CFRelease
27
+
import platform.Foundation.NSError
import platform.Foundation.NSURL
import platform.Foundation.NSValue
30
+
import platform.Foundation.NSFileManager
@OptIn(ExperimentalForeignApi::class)
actual suspend fun extractFrame(
···
58
+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
59
+
actual suspend fun listVideoFrameTimestamps(
61
+
): List<Long> = withContext(Dispatchers.IO) {
62
+
val out = mutableListOf<Long>()
65
+
val resolvedUrl = createUrl(url = videoPath)
66
+
println("[iOS] listVideoFrameTimestamps using URL: ${'$'}{resolvedUrl.absoluteString}")
68
+
// AVAssetReader requires a local file URL. Bail out early for remote URLs.
69
+
if (!resolvedUrl.isFileURL()) {
70
+
println("AVAssetReader requires a local file URL; got: ${'$'}{resolvedUrl.scheme}:// ...")
71
+
return@withContext out
74
+
// Verify the file actually exists at path
75
+
val path = resolvedUrl.path
76
+
if (path == null || !NSFileManager.defaultManager.fileExistsAtPath(path)) {
77
+
println("File does not exist at path: ${'$'}path")
78
+
return@withContext out
81
+
val asset = AVAsset.assetWithURL(resolvedUrl)
83
+
// Use synchronous approach - get tracks directly using tracksWithMediaType
84
+
val videoTracks = asset.tracksWithMediaType(AVMediaTypeVideo)
86
+
if (videoTracks.isEmpty()) {
87
+
println("No video tracks found in video: ${'$'}videoPath")
88
+
return@withContext out
91
+
val videoTrack = videoTracks.first() as AVAssetTrack
92
+
println("Found video track: ${'$'}{videoTrack}")
95
+
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
96
+
val reader = AVAssetReader.assetReaderWithAsset(asset, error = errorPtr.ptr)
98
+
if (reader == null) {
99
+
val err = errorPtr.value
100
+
println("Failed to create AVAssetReader: ${'$'}{err?.localizedDescription}")
101
+
return@withContext out
104
+
val trackOutput = AVAssetReaderTrackOutput.assetReaderTrackOutputWithTrack(
105
+
track = videoTrack,
106
+
outputSettings = null
109
+
if (!reader.canAddOutput(trackOutput)) {
110
+
println("Cannot add track output to reader")
111
+
return@withContext out
114
+
reader.addOutput(trackOutput)
116
+
if (!reader.startReading()) {
117
+
println("Failed to start reading: ${'$'}{reader.error?.localizedDescription}")
118
+
return@withContext out
121
+
println("Started reading video frames...")
124
+
val sampleBuffer = trackOutput.copyNextSampleBuffer()
125
+
if (sampleBuffer == null) {
129
+
val presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
130
+
val timeInSeconds = CMTimeGetSeconds(presentationTime)
131
+
val timeInMillis = (timeInSeconds * 1000.0).toLong()
133
+
out.add(timeInMillis)
136
+
// Release the sample buffer
137
+
CFRelease(sampleBuffer)
139
+
if (out.size >= Int.MAX_VALUE) break
142
+
println("Extracted ${'$'}frameCount frame timestamps")
145
+
} catch (e: Exception) {
146
+
println("Exception in listVideoFrameTimestamps: ${'$'}{e.message}")
147
+
e.printStackTrace()
150
+
println("Returning ${'$'}{out.size} timestamps")
fun createUrl(url: String): NSURL {
43
-
return NSURL.fileURLWithPath(url)
155
+
val trimmed = url.trim()
157
+
// Remote URLs: return as-is
158
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
159
+
return requireNotNull(NSURL.URLWithString(trimmed)) { "Invalid http(s) URL: ${'$'}trimmed" }
162
+
// Well-formed file URLs
163
+
if (trimmed.startsWith("file://")) {
164
+
// Normalize to a proper file URL via its path component to avoid odd host parts
165
+
NSURL.URLWithString(trimmed)?.let { fileUrl ->
166
+
fileUrl.path?.let { path ->
167
+
return NSURL.fileURLWithPath(path)
173
+
// Malformed file: prefix (e.g., "file:/var/..."), coerce to path
174
+
if (trimmed.startsWith("file:")) {
175
+
var path = trimmed.removePrefix("file:")
176
+
// Remove duplicate leading slashes
177
+
while (path.startsWith("//")) path = path.removePrefix("//")
178
+
if (!path.startsWith("/")) path = "/${'$'}path"
179
+
return NSURL.fileURLWithPath(path)
182
+
// Otherwise treat as a filesystem path
183
+
return NSURL.fileURLWithPath(trimmed)
actual object VideoExtractionContext