2727import com .mentra .asg_client .io .media .interfaces .ServiceCallbackInterface ;
2828import com .mentra .asg_client .io .media .managers .MediaUploadQueueManager ;
2929import com .mentra .asg_client .io .media .upload .MediaUploadService ;
30+ import com .mentra .asg_client .io .media .utils .VideoThumbnailWriter ;
3031import com .mentra .asg_client .io .storage .StorageManager ;
3132import com .mentra .asg_client .io .streaming .services .RtmpStreamingService ;
3233import com .mentra .asg_client .io .streaming .services .SrtStreamingService ;
6566import java .util .concurrent .ConcurrentHashMap ;
6667import java .util .concurrent .ExecutorService ;
6768import java .util .concurrent .Executors ;
69+ import java .util .concurrent .Future ;
6870import java .util .concurrent .RejectedExecutionException ;
6971import java .util .concurrent .TimeUnit ;
7072import java .util .concurrent .atomic .AtomicBoolean ;
@@ -148,6 +150,64 @@ private static final class UploadTarget {
148150 }
149151 }
150152
153+ private final class VideoThumbnailTask
154+ implements Runnable , VideoThumbnailWriter .SidecarCommitter {
155+ private final File videoFile ;
156+ private final Object commitLock = new Object ();
157+ private volatile Future <?> execution ;
158+ private boolean discarded ;
159+
160+ VideoThumbnailTask (String filePath ) {
161+ videoFile = new File (filePath );
162+ }
163+
164+ void setExecution (Future <?> execution ) {
165+ this .execution = execution ;
166+ }
167+
168+ @ Override
169+ public void run () {
170+ try {
171+ VideoThumbnailWriter .writeSidecar (videoFile , this );
172+ } finally {
173+ videoThumbnailTasks .remove (videoFile .getAbsolutePath (), this );
174+ }
175+ }
176+
177+ @ Override
178+ public boolean commit (File partial , File sidecar ) {
179+ synchronized (commitLock ) {
180+ return !discarded && videoFile .isFile () && partial .renameTo (sidecar );
181+ }
182+ }
183+
184+ boolean discardAndDeleteVideo () {
185+ synchronized (commitLock ) {
186+ discarded = true ;
187+ Future <?> currentExecution = execution ;
188+ if (currentExecution != null ) {
189+ currentExecution .cancel (true );
190+ }
191+ boolean deleted = !videoFile .exists () || videoFile .delete ();
192+ if (deleted ) {
193+ VideoThumbnailWriter .deleteSidecar (videoFile );
194+ }
195+ // Even if deletion fails, discarded prevents this task from committing late.
196+ return deleted ;
197+ }
198+ }
199+
200+ void cancelThumbnailWrite () {
201+ synchronized (commitLock ) {
202+ discarded = true ;
203+ Future <?> currentExecution = execution ;
204+ if (currentExecution != null ) {
205+ currentExecution .cancel (true );
206+ }
207+ }
208+ }
209+ }
210+
151211 // Guards the stop prologue (mCurrentStopReason + pending-upload target) so the
152212 // check-and-set is atomic across threads. The user stop arrives on the BLE worker
153213 // thread while auto-stops (max-duration/battery/error) fire on the main looper; without
@@ -616,6 +676,19 @@ private void clearBlePhotoTimingTracking(String requestId) {
616676 return t ;
617677 });
618678
679+ /** Keeps best-effort thumbnail decoding from delaying integrity callbacks or uploads. */
680+ private final ExecutorService videoThumbnailExecutor =
681+ Executors .newSingleThreadExecutor (
682+ r -> {
683+ Thread t = new Thread (r , "VideoThumbnailWriter" );
684+ t .setPriority (Thread .NORM_PRIORITY - 1 );
685+ t .setDaemon (true );
686+ return t ;
687+ });
688+ private final ConcurrentHashMap <String , VideoThumbnailTask > videoThumbnailTasks =
689+ new ConcurrentHashMap <>();
690+ private final Object videoThumbnailLifecycleLock = new Object ();
691+
619692 /**
620693 * Keeps ML Kit and final-crop persistence off CameraNeo's shared callback executor. A single
621694 * worker preserves burst ordering and avoids running multiple memory-heavy full-resolution
@@ -1406,6 +1479,9 @@ public void onRecordingStopped(String videoId, String filePath) {
14061479 final boolean ok =
14071480 RecordedVideoIntegrityChecker .verify (
14081481 filePath );
1482+ if (ok ) {
1483+ scheduleVideoThumbnail (filePath );
1484+ }
14091485 mainHandler .post (
14101486 () -> {
14111487 videoCaptureIdsPendingIntegrityCheck
@@ -4366,7 +4442,7 @@ private void performDirectVideoUpload(
43664442
43674443 if (!save ) {
43684444 try {
4369- if (videoFile . delete ( )) {
4445+ if (discardThumbnailAndDeleteVideo ( videoFile )) {
43704446 Log .d (
43714447 TAG ,
43724448 "🗑️ Deleted video file after successful"
@@ -6771,6 +6847,41 @@ private void stopBatteryMonitoring() {
67716847 }
67726848 }
67736849
6850+ private void scheduleVideoThumbnail (String filePath ) {
6851+ String taskKey = new File (filePath ).getAbsolutePath ();
6852+ VideoThumbnailTask task = new VideoThumbnailTask (taskKey );
6853+ synchronized (videoThumbnailLifecycleLock ) {
6854+ // Cleanup blocks new integrity checks first, then lets checks already in flight enqueue
6855+ // their sidecars before closing this executor.
6856+ if (videoThumbnailExecutor .isShutdown ()) {
6857+ return ;
6858+ }
6859+ if (videoThumbnailTasks .putIfAbsent (taskKey , task ) != null ) {
6860+ return ;
6861+ }
6862+ try {
6863+ task .setExecution (videoThumbnailExecutor .submit (task ));
6864+ } catch (RejectedExecutionException e ) {
6865+ videoThumbnailTasks .remove (taskKey , task );
6866+ Log .d (TAG , "Skipping video thumbnail during cleanup" );
6867+ }
6868+ }
6869+ }
6870+
6871+ private boolean discardThumbnailAndDeleteVideo (File videoFile ) {
6872+ VideoThumbnailTask task = videoThumbnailTasks .get (videoFile .getAbsolutePath ());
6873+ if (task != null ) {
6874+ boolean deleted = task .discardAndDeleteVideo ();
6875+ videoThumbnailTasks .remove (videoFile .getAbsolutePath (), task );
6876+ return deleted ;
6877+ }
6878+ boolean deleted = !videoFile .exists () || videoFile .delete ();
6879+ if (deleted ) {
6880+ VideoThumbnailWriter .deleteSidecar (videoFile );
6881+ }
6882+ return deleted ;
6883+ }
6884+
67746885 /**
67756886 * Cleanup resources and stop all monitoring. MUST be called before service is destroyed to
67766887 * prevent leaks.
@@ -6796,6 +6907,9 @@ public void cleanup() {
67966907 mBatteryMonitorHandler = null ;
67976908 }
67986909
6910+ // isCleaningUp prevents new integrity submissions. Let checks already in flight enqueue
6911+ // their thumbnails before closing thumbnail submission; existing thumbnail work still
6912+ // drains in parallel with this wait.
67996913 videoIntegrityExecutor .shutdown ();
68006914 try {
68016915 if (!videoIntegrityExecutor .awaitTermination (3 , TimeUnit .SECONDS )) {
@@ -6805,6 +6919,26 @@ public void cleanup() {
68056919 videoIntegrityExecutor .shutdownNow ();
68066920 Thread .currentThread ().interrupt ();
68076921 }
6922+ synchronized (videoThumbnailLifecycleLock ) {
6923+ videoThumbnailExecutor .shutdown ();
6924+ }
6925+ try {
6926+ if (!videoThumbnailExecutor .awaitTermination (
6927+ AsgConstants .VIDEO_THUMBNAIL_SHUTDOWN_TIMEOUT_MS ,
6928+ TimeUnit .MILLISECONDS )) {
6929+ videoThumbnailTasks
6930+ .values ()
6931+ .forEach (VideoThumbnailTask ::cancelThumbnailWrite );
6932+ videoThumbnailExecutor .shutdownNow ();
6933+ }
6934+ } catch (InterruptedException e ) {
6935+ videoThumbnailTasks .values ().forEach (VideoThumbnailTask ::cancelThumbnailWrite );
6936+ videoThumbnailExecutor .shutdownNow ();
6937+ Thread .currentThread ().interrupt ();
6938+ } finally {
6939+ VideoThumbnailWriter .shutdownFrameExtraction ();
6940+ }
6941+ videoThumbnailTasks .clear ();
68086942 textModeProcessingExecutor .shutdown ();
68096943 try {
68106944 if (!textModeProcessingExecutor .awaitTermination (3 , TimeUnit .SECONDS )) {
0 commit comments