@@ -21,7 +21,6 @@ import androidx.camera.video.FileOutputOptions
2121import androidx.camera.video.Quality
2222import androidx.camera.video.QualitySelector
2323import androidx.camera.video.Recorder
24- import androidx.camera.video.Recording
2524import androidx.camera.video.VideoCapture
2625import androidx.camera.video.VideoRecordEvent
2726import androidx.core.content.ContextCompat
@@ -44,6 +43,54 @@ import kotlin.math.roundToInt
4443/* *
4544 * CameraX-backed capture service used by gateway camera commands.
4645 */
46+ internal class CameraClipSession (
47+ private val unbind : () -> Unit ,
48+ private val deleteTemporaryFile : (File ) -> Unit ,
49+ ) : AutoCloseable {
50+ private var recording: AutoCloseable ? = null
51+ private var temporaryFile: File ? = null
52+ private var closed = false
53+
54+ fun ownRecording (recording : AutoCloseable ) {
55+ check(! closed) { " camera clip session is closed" }
56+ this .recording = recording
57+ }
58+
59+ fun ownFile (file : File ): File {
60+ check(! closed) { " camera clip session is closed" }
61+ check(temporaryFile == null ) { " camera clip session already owns a file" }
62+ temporaryFile = file
63+ return file
64+ }
65+
66+ fun transferFile (): File {
67+ check(! closed) { " camera clip session is closed" }
68+ return checkNotNull(temporaryFile) { " camera clip session has no file" }
69+ .also { temporaryFile = null }
70+ }
71+
72+ override fun close () {
73+ if (closed) return
74+ closed = true
75+
76+ var failure: Throwable ? = null
77+
78+ fun cleanup (action : () -> Unit ) {
79+ try {
80+ action()
81+ } catch (err: Throwable ) {
82+ failure?.addSuppressed(err) ? : run { failure = err }
83+ }
84+ }
85+
86+ // Keep teardown symmetric across bind, warmup, recording, finalize, and success exits.
87+ cleanup { recording?.close() }
88+ cleanup(unbind)
89+ temporaryFile?.let { file -> cleanup { deleteTemporaryFile(file) } }
90+ failure?.let { throw it }
91+ }
92+ }
93+
4794class CameraCaptureManager (
4895 private val context : Context ,
4996) {
@@ -235,87 +282,90 @@ class CameraCaptureManager(
235282 androidx.camera.core.Preview
236283 .Builder ()
237284 .build()
238- // Provide a dummy SurfaceTexture so the preview pipeline activates
239- val surfaceTexture = android.graphics.SurfaceTexture (0 )
240- surfaceTexture.setDefaultBufferSize(640 , 480 )
285+ // Allocate the dummy preview surface only after CameraX requests it; its result owns release.
241286 preview.setSurfaceProvider { request ->
287+ val surfaceTexture = android.graphics.SurfaceTexture (0 )
288+ surfaceTexture.setDefaultBufferSize(640 , 480 )
242289 val surface = android.view.Surface (surfaceTexture)
243- request.provideSurface(surface, context.mainExecutor()) { result ->
290+ request.provideSurface(surface, context.mainExecutor()) {
244291 surface.release()
245292 surfaceTexture.release()
246293 }
247294 }
248295
249296 provider.unbindAll()
250- android.util.Log .w(" CameraCaptureManager" , " clip: binding preview + videoCapture to lifecycle" )
251- val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
252- android.util.Log .w(" CameraCaptureManager" , " clip: bound, cameraInfo=${camera.cameraInfo} " )
253-
254- // Give camera pipeline time to initialize before recording
255- android.util.Log .w(" CameraCaptureManager" , " clip: warming up camera 1.5s..." )
256- kotlinx.coroutines.delay(1_500 )
257-
258- val file = File .createTempFile(" openclaw-clip-" , " .mp4" , context.cacheDir)
259- val outputOptions = FileOutputOptions .Builder (file).build()
260-
261- val finalized = kotlinx.coroutines.CompletableDeferred <VideoRecordEvent .Finalize >()
262- android.util.Log .w(" CameraCaptureManager" , " clip: starting recording to ${file.absolutePath} " )
263- val recording: Recording =
264- videoCapture.output
265- .prepareRecording(context, outputOptions)
266- .apply {
267- if (includeAudio) withAudioEnabled()
268- }.start(context.mainExecutor()) { event ->
269- android.util.Log .w(" CameraCaptureManager" , " clip: event ${event.javaClass.simpleName} " )
270- if (event is VideoRecordEvent .Status ) {
271- android.util.Log .w(" CameraCaptureManager" , " clip: recording status update" )
272- }
273- if (event is VideoRecordEvent .Finalize ) {
274- android.util.Log .w(
275- " CameraCaptureManager" ,
276- " clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause} " ,
277- )
278- finalized.complete(event)
297+ CameraClipSession (
298+ unbind = { provider.unbind(preview, videoCapture) },
299+ deleteTemporaryFile = { file ->
300+ check(! file.exists() || file.delete()) { " failed to delete temporary camera clip" }
301+ },
302+ ).use { session ->
303+ android.util.Log .w(" CameraCaptureManager" , " clip: binding preview + videoCapture to lifecycle" )
304+ val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
305+ android.util.Log .w(" CameraCaptureManager" , " clip: bound, cameraInfo=${camera.cameraInfo} " )
306+
307+ // Give camera pipeline time to initialize before recording
308+ android.util.Log .w(" CameraCaptureManager" , " clip: warming up camera 1.5s..." )
309+ kotlinx.coroutines.delay(1_500 )
310+
311+ val clipFile = session.ownFile(File .createTempFile(" openclaw-clip-" , " .mp4" , context.cacheDir))
312+ val outputOptions = FileOutputOptions .Builder (clipFile).build()
313+
314+ val finalized = kotlinx.coroutines.CompletableDeferred <VideoRecordEvent .Finalize >()
315+ android.util.Log .w(" CameraCaptureManager" , " clip: starting recording to ${clipFile.absolutePath} " )
316+ val recording =
317+ videoCapture.output
318+ .prepareRecording(context, outputOptions)
319+ .apply {
320+ if (includeAudio) withAudioEnabled()
321+ }.start(context.mainExecutor()) { event ->
322+ android.util.Log .w(" CameraCaptureManager" , " clip: event ${event.javaClass.simpleName} " )
323+ if (event is VideoRecordEvent .Status ) {
324+ android.util.Log .w(" CameraCaptureManager" , " clip: recording status update" )
325+ }
326+ if (event is VideoRecordEvent .Finalize ) {
327+ android.util.Log .w(
328+ " CameraCaptureManager" ,
329+ " clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause} " ,
330+ )
331+ finalized.complete(event)
332+ }
279333 }
280- }
334+ session.ownRecording(recording)
281335
282- android.util.Log .w(" CameraCaptureManager" , " clip: recording started, delaying ${durationMs} ms" )
283- try {
336+ android.util.Log .w(" CameraCaptureManager" , " clip: recording started, delaying ${durationMs} ms" )
284337 kotlinx.coroutines.delay(durationMs.toLong())
285- } finally {
286338 android.util.Log .w(" CameraCaptureManager" , " clip: stopping recording" )
287- recording.stop()
288- }
339+ recording.close()
289340
290- val finalizeEvent =
291- try {
292- withTimeout(15_000 ) { finalized.await() }
293- } catch (err: Throwable ) {
294- android.util.Log .e(" CameraCaptureManager" , " clip: finalize timed out" , err)
295- withContext(Dispatchers .IO ) { file.delete() }
296- provider.unbindAll()
297- throw IllegalStateException (" UNAVAILABLE: camera clip finalize timed out" )
341+ val finalizeEvent =
342+ try {
343+ withTimeout(15_000 ) { finalized.await() }
344+ } catch (err: kotlinx.coroutines.TimeoutCancellationException ) {
345+ android.util.Log .e(" CameraCaptureManager" , " clip: finalize timed out" , err)
346+ throw IllegalStateException (" UNAVAILABLE: camera clip finalize timed out" )
347+ }
348+ if (finalizeEvent.hasError()) {
349+ android.util.Log .e(
350+ " CameraCaptureManager" ,
351+ " clip: FAILED error=${finalizeEvent.error} , cause=${finalizeEvent.cause} " ,
352+ finalizeEvent.cause,
353+ )
354+ // Check file size for debugging
355+ val fileSize = withContext(Dispatchers .IO ) { if (clipFile.exists()) clipFile.length() else - 1 }
356+ android.util.Log .e(" CameraCaptureManager" , " clip: file exists=${clipFile.exists()} size=$fileSize " )
357+ throw IllegalStateException (" UNAVAILABLE: camera clip failed (error=${finalizeEvent.error} )" )
298358 }
299- if (finalizeEvent.hasError()) {
300- android.util.Log .e(
301- " CameraCaptureManager" ,
302- " clip: FAILED error=${finalizeEvent.error} , cause=${finalizeEvent.cause} " ,
303- finalizeEvent.cause,
304- )
305- // Check file size for debugging
306- val fileSize = withContext(Dispatchers .IO ) { if (file.exists()) file.length() else - 1 }
307- android.util.Log .e(" CameraCaptureManager" , " clip: file exists=${file.exists()} size=$fileSize " )
308- withContext(Dispatchers .IO ) { file.delete() }
309- provider.unbindAll()
310- throw IllegalStateException (" UNAVAILABLE: camera clip failed (error=${finalizeEvent.error} )" )
311- }
312359
313- val fileSize = withContext(Dispatchers .IO ) { file .length() }
314- android.util.Log .w(" CameraCaptureManager" , " clip: SUCCESS file size=$fileSize " )
360+ val fileSize = withContext(Dispatchers .IO ) { clipFile .length() }
361+ android.util.Log .w(" CameraCaptureManager" , " clip: SUCCESS file size=$fileSize " )
315362
316- provider.unbindAll()
317-
318- FilePayload (file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
363+ FilePayload (
364+ file = session.transferFile(),
365+ durationMs = durationMs.toLong(),
366+ hasAudio = includeAudio,
367+ )
368+ }
319369 }
320370
321371 private fun rotateBitmapByExif (
0 commit comments