Skip to content

Commit 418ba69

Browse files
authored
Merge pull request #3665 from Mentra-Community/aisraelov/video-thumbnails
Add thumb.jpg sidecar for finished video captures
2 parents 1cbda01 + b77eef6 commit 418ba69

6 files changed

Lines changed: 1027 additions & 2 deletions

File tree

asg_client/app/src/main/java/com/mentra/asg_client/AsgConstants.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,33 @@ public class AsgConstants {
364364
*/
365365
public static final int FILE_TRANSFER_PROGRESS_LOG_INTERVAL = 10;
366366

367+
// Video capture thumbnails
368+
// -------------------------------------------------------------------------
369+
370+
/** JPEG sidecar written next to a finalized video for direct-filesystem consumers. */
371+
public static final String VIDEO_THUMBNAIL_SIDECAR_NAME = "thumb.jpg";
372+
373+
/** Transient thumbnail filename; the .partial suffix keeps it out of gallery listings. */
374+
public static final String VIDEO_THUMBNAIL_PARTIAL_NAME = "thumb.jpg.partial";
375+
376+
/** Longest edge of generated video thumbnails, in pixels. */
377+
public static final int VIDEO_THUMBNAIL_MAX_DIMENSION = 480;
378+
379+
/** JPEG compression quality for video thumbnail sidecars. */
380+
public static final int VIDEO_THUMBNAIL_JPEG_QUALITY = 80;
381+
382+
/** Frame position sampled for video thumbnails, in microseconds. */
383+
public static final long VIDEO_THUMBNAIL_FRAME_TIME_US = 1_000_000L;
384+
385+
/** Maximum time allowed for platform video-frame extraction. */
386+
public static final long VIDEO_THUMBNAIL_EXTRACTION_TIMEOUT_MS = 10_000L;
387+
388+
/** Final main-thread wait after thumbnail work has drained during other cleanup steps. */
389+
public static final long VIDEO_THUMBNAIL_SHUTDOWN_TIMEOUT_MS = 250L;
390+
391+
/** Maximum abandoned native decoder workers retained after timeout. */
392+
public static final int VIDEO_THUMBNAIL_MAX_RETIRED_DECODERS = 2;
393+
367394
/**
368395
* Max wait for the deferred background photo write ({@code CapturedPhoto.persistence}) when a
369396
* BLE photo consumer needs the file on disk (gallery save, text-mode canonical crop, cleanup).

asg_client/app/src/main/java/com/mentra/asg_client/io/file/core/FileManagerImpl.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.mentra.asg_client.io.file.core;
22

3+
import com.mentra.asg_client.AsgConstants;
34
import com.mentra.asg_client.logging.Logger;
45
import com.mentra.asg_client.io.file.managers.FileOperationsManager;
56
import com.mentra.asg_client.io.file.managers.FileSecurityManager;
@@ -324,6 +325,16 @@ private int collectFilesRecursively(File directory, File rootDir, String package
324325
Log.d(TAG, "⏭️ Skipping .partial file from listing: " + item.getAbsolutePath());
325326
continue;
326327
}
328+
// Skip thumb.jpg capture sidecars (written at video finalize for USB/desktop
329+
// consumers). The Wi-Fi gallery has its own thumbnail cache; listing these
330+
// would advertise them to phone sync as primary capture files.
331+
if (getDefaultPackageName().equals(packageName)
332+
&& directory.getName().startsWith("VID_")
333+
&& item.getName()
334+
.equalsIgnoreCase(
335+
AsgConstants.VIDEO_THUMBNAIL_SIDECAR_NAME)) {
336+
continue;
337+
}
327338
// Add file to metadata list
328339
String mimeType = new MimeTypeRegistry().getMimeType(item.getName());
329340
String relativePath = getRelativePath(rootDir, item);
@@ -794,8 +805,10 @@ private void cleanupOrphanedCaptures() {
794805
lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".avi");
795806
// Exclude HDR brackets — they're not standalone media
796807
boolean isBracket = lower.matches("ev-?\\d+\\.jpe?g");
808+
boolean isVideoThumbnail =
809+
lower.equals(AsgConstants.VIDEO_THUMBNAIL_SIDECAR_NAME);
797810

798-
if (isMediaExtension && !isBracket) {
811+
if (isMediaExtension && !isBracket && !isVideoThumbnail) {
799812
boolean isVideo = lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".avi");
800813
if (isVideo) {
801814
// Videos need moov atom validation — a killed process leaves

asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.mentra.asg_client.io.media.interfaces.ServiceCallbackInterface;
2828
import com.mentra.asg_client.io.media.managers.MediaUploadQueueManager;
2929
import com.mentra.asg_client.io.media.upload.MediaUploadService;
30+
import com.mentra.asg_client.io.media.utils.VideoThumbnailWriter;
3031
import com.mentra.asg_client.io.storage.StorageManager;
3132
import com.mentra.asg_client.io.streaming.services.RtmpStreamingService;
3233
import com.mentra.asg_client.io.streaming.services.SrtStreamingService;
@@ -65,6 +66,7 @@
6566
import java.util.concurrent.ConcurrentHashMap;
6667
import java.util.concurrent.ExecutorService;
6768
import java.util.concurrent.Executors;
69+
import java.util.concurrent.Future;
6870
import java.util.concurrent.RejectedExecutionException;
6971
import java.util.concurrent.TimeUnit;
7072
import 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

Comments
 (0)