alpha
Login
or
Join now
nateholland.bsky.social
/
PoseDetection
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
feat: retrieve timestamps from video
author
nathan holland
date
10 months ago
(Aug 25, 2025, 5:49 PM +0300)
commit
544031ae
544031aeae868f1d7884b4398a91d1070d4a1819
parent
8e58b468
8e58b468d6da19e0b9c88610fc64a421f0810e0c
+198
-2
4 changed files
Expand all
Collapse all
Unified
Split
posedetection
src
androidMain
kotlin
com
performancecoachlab
posedetection
recording
VideoUtils.android.kt
commonMain
kotlin
com
performancecoachlab
posedetection
recording
VideoUtils.kt
iosMain
kotlin
com
performancecoachlab
posedetection
recording
VideoUtils.ios.kt
sample
composeApp
src
commonMain
kotlin
com
nate
posedetection
App.kt
+46
posedetection/src/androidMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.android.kt
Reviewed
···
2
2
3
3
import android.app.Application
4
4
import android.content.Context
5
5
+
import android.media.MediaExtractor
6
6
+
import android.media.MediaFormat
5
7
import android.media.MediaMetadataRetriever
6
8
import kotlinx.coroutines.Dispatchers
7
9
import kotlinx.coroutines.withContext
···
37
39
}
38
40
return@withContext null
39
41
}
42
42
+
}
43
43
+
44
44
+
actual suspend fun listVideoFrameTimestamps(
45
45
+
videoPath: String,
46
46
+
): List<Long> = withContext(Dispatchers.IO) {
47
47
+
val ctx = VideoExtractionContext.get()
48
48
+
val uri = videoPath.toUri()
49
49
+
val extractor = MediaExtractor()
50
50
+
51
51
+
val out = ArrayList<Long>()
52
52
+
try {
53
53
+
extractor.setDataSource(ctx, uri, null)
54
54
+
55
55
+
// Select video track
56
56
+
var videoTrack = -1
57
57
+
for (i in 0 until extractor.trackCount) {
58
58
+
val format = extractor.getTrackFormat(i)
59
59
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
60
60
+
if (mime.startsWith("video/")) {
61
61
+
videoTrack = i
62
62
+
break
63
63
+
}
64
64
+
}
65
65
+
if (videoTrack == -1) return@withContext out
66
66
+
67
67
+
extractor.selectTrack(videoTrack)
68
68
+
69
69
+
var idx = 0
70
70
+
while (true) {
71
71
+
val sampleTimeUs = extractor.sampleTime
72
72
+
if (sampleTimeUs < 0) break
73
73
+
74
74
+
out.add(sampleTimeUs / 1000L) // ms
75
75
+
if (out.size >= Int.MAX_VALUE) break
76
76
+
idx++
77
77
+
78
78
+
if (!extractor.advance()) break
79
79
+
}
80
80
+
} catch (_: Throwable) {
81
81
+
// swallow; return what we have
82
82
+
} finally {
83
83
+
extractor.release()
84
84
+
}
85
85
+
out
40
86
}
41
87
42
88
actual object VideoExtractionContext {
+4
posedetection/src/commonMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.kt
Reviewed
···
4
4
videoPath: String, frameTimestamp: Long
5
5
): InputFrame?
6
6
7
7
+
expect suspend fun listVideoFrameTimestamps(
8
8
+
videoPath: String,
9
9
+
): List<Long>
10
10
+
7
11
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
8
12
expect object VideoExtractionContext
+141
-1
posedetection/src/iosMain/kotlin/com/performancecoachlab/posedetection/recording/VideoUtils.ios.kt
Reviewed
···
1
1
package com.performancecoachlab.posedetection.recording
2
2
3
3
+
import kotlinx.cinterop.BetaInteropApi
3
4
import kotlinx.cinterop.ExperimentalForeignApi
5
5
+
import kotlinx.cinterop.ObjCObjectVar
6
6
+
import kotlinx.cinterop.alloc
7
7
+
import kotlinx.cinterop.memScoped
8
8
+
import kotlinx.cinterop.ptr
9
9
+
import kotlinx.cinterop.value
4
10
import kotlinx.coroutines.Dispatchers
5
11
import kotlinx.coroutines.IO
6
12
import kotlinx.coroutines.withContext
7
13
import platform.AVFoundation.AVAsset
8
14
import platform.AVFoundation.AVAssetImageGenerator
15
15
+
import platform.AVFoundation.AVAssetReader
16
16
+
import platform.AVFoundation.AVAssetReaderTrackOutput
17
17
+
import platform.AVFoundation.AVAssetTrack
18
18
+
import platform.AVFoundation.AVMediaTypeVideo
9
19
import platform.AVFoundation.CMTimeValue
20
20
+
import platform.AVFoundation.tracksWithMediaType
10
21
import platform.AVFoundation.valueWithCMTime
11
22
import platform.CoreMedia.CMTimeMake
12
23
import platform.CoreMedia.CMTimeMakeWithSeconds
24
24
+
import platform.CoreMedia.CMTimeGetSeconds
25
25
+
import platform.CoreMedia.CMSampleBufferGetPresentationTimeStamp
26
26
+
import platform.CoreFoundation.CFRelease
27
27
+
import platform.Foundation.NSError
13
28
import platform.Foundation.NSURL
14
29
import platform.Foundation.NSValue
30
30
+
import platform.Foundation.NSFileManager
15
31
16
32
@OptIn(ExperimentalForeignApi::class)
17
33
actual suspend fun extractFrame(
···
39
55
}
40
56
}
41
57
58
58
+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
59
59
+
actual suspend fun listVideoFrameTimestamps(
60
60
+
videoPath: String,
61
61
+
): List<Long> = withContext(Dispatchers.IO) {
62
62
+
val out = mutableListOf<Long>()
63
63
+
64
64
+
try {
65
65
+
val resolvedUrl = createUrl(url = videoPath)
66
66
+
println("[iOS] listVideoFrameTimestamps using URL: ${'$'}{resolvedUrl.absoluteString}")
67
67
+
68
68
+
// AVAssetReader requires a local file URL. Bail out early for remote URLs.
69
69
+
if (!resolvedUrl.isFileURL()) {
70
70
+
println("AVAssetReader requires a local file URL; got: ${'$'}{resolvedUrl.scheme}:// ...")
71
71
+
return@withContext out
72
72
+
}
73
73
+
74
74
+
// Verify the file actually exists at path
75
75
+
val path = resolvedUrl.path
76
76
+
if (path == null || !NSFileManager.defaultManager.fileExistsAtPath(path)) {
77
77
+
println("File does not exist at path: ${'$'}path")
78
78
+
return@withContext out
79
79
+
}
80
80
+
81
81
+
val asset = AVAsset.assetWithURL(resolvedUrl)
82
82
+
83
83
+
// Use synchronous approach - get tracks directly using tracksWithMediaType
84
84
+
val videoTracks = asset.tracksWithMediaType(AVMediaTypeVideo)
85
85
+
86
86
+
if (videoTracks.isEmpty()) {
87
87
+
println("No video tracks found in video: ${'$'}videoPath")
88
88
+
return@withContext out
89
89
+
}
90
90
+
91
91
+
val videoTrack = videoTracks.first() as AVAssetTrack
92
92
+
println("Found video track: ${'$'}{videoTrack}")
93
93
+
94
94
+
memScoped {
95
95
+
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
96
96
+
val reader = AVAssetReader.assetReaderWithAsset(asset, error = errorPtr.ptr)
97
97
+
98
98
+
if (reader == null) {
99
99
+
val err = errorPtr.value
100
100
+
println("Failed to create AVAssetReader: ${'$'}{err?.localizedDescription}")
101
101
+
return@withContext out
102
102
+
}
103
103
+
104
104
+
val trackOutput = AVAssetReaderTrackOutput.assetReaderTrackOutputWithTrack(
105
105
+
track = videoTrack,
106
106
+
outputSettings = null
107
107
+
)
108
108
+
109
109
+
if (!reader.canAddOutput(trackOutput)) {
110
110
+
println("Cannot add track output to reader")
111
111
+
return@withContext out
112
112
+
}
113
113
+
114
114
+
reader.addOutput(trackOutput)
115
115
+
116
116
+
if (!reader.startReading()) {
117
117
+
println("Failed to start reading: ${'$'}{reader.error?.localizedDescription}")
118
118
+
return@withContext out
119
119
+
}
120
120
+
121
121
+
println("Started reading video frames...")
122
122
+
var frameCount = 0
123
123
+
while (true) {
124
124
+
val sampleBuffer = trackOutput.copyNextSampleBuffer()
125
125
+
if (sampleBuffer == null) {
126
126
+
break
127
127
+
}
128
128
+
129
129
+
val presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
130
130
+
val timeInSeconds = CMTimeGetSeconds(presentationTime)
131
131
+
val timeInMillis = (timeInSeconds * 1000.0).toLong()
132
132
+
133
133
+
out.add(timeInMillis)
134
134
+
frameCount++
135
135
+
136
136
+
// Release the sample buffer
137
137
+
CFRelease(sampleBuffer)
138
138
+
139
139
+
if (out.size >= Int.MAX_VALUE) break
140
140
+
}
141
141
+
142
142
+
println("Extracted ${'$'}frameCount frame timestamps")
143
143
+
}
144
144
+
145
145
+
} catch (e: Exception) {
146
146
+
println("Exception in listVideoFrameTimestamps: ${'$'}{e.message}")
147
147
+
e.printStackTrace()
148
148
+
}
149
149
+
150
150
+
println("Returning ${'$'}{out.size} timestamps")
151
151
+
out
152
152
+
}
153
153
+
42
154
fun createUrl(url: String): NSURL {
43
43
-
return NSURL.fileURLWithPath(url)
155
155
+
val trimmed = url.trim()
156
156
+
157
157
+
// Remote URLs: return as-is
158
158
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
159
159
+
return requireNotNull(NSURL.URLWithString(trimmed)) { "Invalid http(s) URL: ${'$'}trimmed" }
160
160
+
}
161
161
+
162
162
+
// Well-formed file URLs
163
163
+
if (trimmed.startsWith("file://")) {
164
164
+
// Normalize to a proper file URL via its path component to avoid odd host parts
165
165
+
NSURL.URLWithString(trimmed)?.let { fileUrl ->
166
166
+
fileUrl.path?.let { path ->
167
167
+
return NSURL.fileURLWithPath(path)
168
168
+
}
169
169
+
return fileUrl
170
170
+
}
171
171
+
}
172
172
+
173
173
+
// Malformed file: prefix (e.g., "file:/var/..."), coerce to path
174
174
+
if (trimmed.startsWith("file:")) {
175
175
+
var path = trimmed.removePrefix("file:")
176
176
+
// Remove duplicate leading slashes
177
177
+
while (path.startsWith("//")) path = path.removePrefix("//")
178
178
+
if (!path.startsWith("/")) path = "/${'$'}path"
179
179
+
return NSURL.fileURLWithPath(path)
180
180
+
}
181
181
+
182
182
+
// Otherwise treat as a filesystem path
183
183
+
return NSURL.fileURLWithPath(trimmed)
44
184
}
45
185
46
186
actual object VideoExtractionContext
+7
-1
sample/composeApp/src/commonMain/kotlin/com/nate/posedetection/App.kt
Reviewed
···
49
49
import com.performancecoachlab.posedetection.recording.FrameAnalyser
50
50
import com.performancecoachlab.posedetection.recording.InputFrame
51
51
import com.performancecoachlab.posedetection.recording.extractFrame
52
52
+
import com.performancecoachlab.posedetection.recording.listVideoFrameTimestamps
52
53
import com.performancecoachlab.posedetection.skeleton.Pose
53
54
import com.performancecoachlab.posedetection.skeleton.SkeletonRepository
54
55
import io.github.vinceglb.filekit.FileKit
···
68
69
69
70
@Composable
70
71
internal fun App() = AppTheme {
71
71
-
var selectedTabIndex by remember { mutableStateOf(0) }
72
72
+
var selectedTabIndex by remember { mutableStateOf(1) }
72
73
val tabs = listOf("Camera Feed", "Recorded Video")
73
74
Column {
74
75
TabRow(selectedTabIndex = selectedTabIndex) {
···
184
185
e.printStackTrace()
185
186
}
186
187
}
188
188
+
}
189
189
+
190
190
+
LaunchedEffect(url){
191
191
+
val timestamps = listVideoFrameTimestamps(url)
192
192
+
println("Timestamps: $timestamps")
187
193
}
188
194
189
195
LaunchedEffect(url, frame) {