From 0efcd0b051ffe8d7edf20870106f09ccb3a294ae Mon Sep 17 00:00:00 2001 From: 1vedantshinde <2vedsh@gmail.com> Date: Fri, 17 Apr 2026 19:05:06 +0530 Subject: [PATCH] created a cancel button during frame extraction process --- .../java/com/splats/app/MainActivity.java | 23 ++++- .../com/splats/app/VideoFrameExtractor.java | 84 ++++++++++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/brush-app/app/src/main/java/com/splats/app/MainActivity.java b/crates/brush-app/app/src/main/java/com/splats/app/MainActivity.java index d4d73c2f..95122f21 100644 --- a/crates/brush-app/app/src/main/java/com/splats/app/MainActivity.java +++ b/crates/brush-app/app/src/main/java/com/splats/app/MainActivity.java @@ -147,6 +147,8 @@ public static void runTelemetry() { private final Handler mainHandler = new Handler(Looper.getMainLooper()); private CoroutineScope telemetryScope; private TelemetryPreprocessor telemetryPreprocessor; + /** Handle for the current frame-extraction operation; null when idle. */ + private volatile VideoFrameExtractor.CancellationHandle extractionHandle = null; /** Returns the device model string (e.g. "Pixel 9a" or "23049PCD8G" for Poco F5). */ public static String getDeviceModel() { @@ -194,6 +196,10 @@ protected void onCreate(Bundle savedInstanceState) { @Override protected void onDestroy() { + if (extractionHandle != null) { + extractionHandle.cancel(); + extractionHandle = null; + } if (telemetryPreprocessor != null) { telemetryPreprocessor.cancel(); telemetryPreprocessor = null; @@ -506,8 +512,14 @@ private void extractFramesFromSelectedVideo(String json) { params.timesUsRelative = timesUs; } + // Cancel any previous extraction before starting a new one. + if (extractionHandle != null) { + Log.i(TAG, "Cancelling previous extraction before starting a new one"); + extractionHandle.cancel(); + } + Log.i(TAG, "Initiating frame extraction..."); - VideoFrameExtractor.extractFrames(this, uri, params, new VideoFrameExtractor.ExtractionCallback() { + extractionHandle = VideoFrameExtractor.extractFrames(this, uri, params, new VideoFrameExtractor.ExtractionCallback() { @Override public void onProgress(float progress) { notifyPlatformEvent("progress:extracting", String.format(Locale.US, "%.3f", progress)); @@ -515,6 +527,7 @@ public void onProgress(float progress) { @Override public void onFinished() { + extractionHandle = null; runOnUiThread(() -> Toast.makeText(MainActivity.this, "Frames extracted!", Toast.LENGTH_SHORT).show()); notifyPlatformEvent("extraction_complete", ""); if (telemetryMode) { @@ -525,8 +538,16 @@ public void onFinished() { } } + @Override + public void onCancelled() { + extractionHandle = null; + Log.i(TAG, "Frame extraction was cancelled by the user"); + notifyPlatformEvent("extraction_cancelled", ""); + } + @Override public void onFailure(Exception e) { + extractionHandle = null; notifyPlatformEvent("extraction_complete", "error"); } }); diff --git a/crates/brush-app/app/src/main/java/com/splats/app/VideoFrameExtractor.java b/crates/brush-app/app/src/main/java/com/splats/app/VideoFrameExtractor.java index c42ac6e4..b4274473 100644 --- a/crates/brush-app/app/src/main/java/com/splats/app/VideoFrameExtractor.java +++ b/crates/brush-app/app/src/main/java/com/splats/app/VideoFrameExtractor.java @@ -15,6 +15,7 @@ import java.io.File; import java.io.FileOutputStream; +import java.util.concurrent.atomic.AtomicBoolean; public class VideoFrameExtractor { private static final String TAG = "VideoFrameExtractor"; @@ -25,9 +26,36 @@ public class VideoFrameExtractor { public interface ExtractionCallback { void onProgress(float progress); void onFinished(); + void onCancelled(); void onFailure(Exception e); } + /** + * Opaque handle returned by {@link #extractFrames}. Call {@link #cancel()} to + * request a graceful stop of the background extraction thread. + * + *
When cancellation is requested the extraction loop exits at the next frame + * boundary, all {@code frame_*.jpg} files that were already written are deleted, + * and {@link ExtractionCallback#onCancelled()} is dispatched on the main thread. + * The source {@code .mp4} and {@code .csv} files are never touched.
+ */ + public static final class CancellationHandle { + private final AtomicBoolean cancelled = new AtomicBoolean(false); + + /** Returns {@code true} if cancellation has been requested. */ + public boolean isCancelled() { + return cancelled.get(); + } + + /** + * Request cancellation. Safe to call from any thread. + * Has no effect if extraction has already finished. + */ + public void cancel() { + cancelled.set(true); + } + } + public static final class Params { public int frameCount = 50; /** Caps longest image edge during decode (e.g. 144 … 720). Lower = faster. */ @@ -39,10 +67,17 @@ public static final class Params { public long[] timesUsRelative; } - public static void extractFrames(Context context, Uri videoUri, Params params, ExtractionCallback callback) { + /** + * Begin asynchronous frame extraction. + * + * @return a {@link CancellationHandle} that the caller can use to stop the operation. + */ + public static CancellationHandle extractFrames(Context context, Uri videoUri, Params params, ExtractionCallback callback) { final Params extractionParams = params != null ? params : new Params(); cleanupExtractedFrames(context); + final CancellationHandle handle = new CancellationHandle(); + final int totalSteps; if (extractionParams.timesUsRelative != null && extractionParams.timesUsRelative.length > 0) { totalSteps = extractionParams.timesUsRelative.length; @@ -78,6 +113,13 @@ public static void extractFrames(Context context, Uri videoUri, Params params, E builder.setView(layout); builder.setCancelable(false); + // ── Cancel button ────────────────────────────────────── + builder.setNegativeButton("Cancel", (dialog, which) -> { + Log.i(TAG, "User requested cancellation of frame extraction"); + handle.cancel(); + dialog.dismiss(); + }); + AlertDialog dialog = builder.create(); dialog.show(); @@ -111,6 +153,13 @@ public static void extractFrames(Context context, Uri videoUri, Params params, E if (outputDir != null && !outputDir.exists()) outputDir.mkdirs(); int writtenFrames = 0; + // ── Check cancellation before any heavy work ─────────────── + if (handle.isCancelled()) { + retriever.release(); + cancelExtraction(context, dialogHolder[0], callback, outputDir); + return; + } + if (durationMs <= 0 && (extractionParams.timesUsRelative == null || extractionParams.timesUsRelative.length == 0)) { Bitmap single = retriever.getFrameAtTime(0); if (single != null) { @@ -130,6 +179,15 @@ public static void extractFrames(Context context, Uri videoUri, Params params, E Log.i(TAG, "Starting extraction loop. Steps: " + totalSteps); long lastTimeUs = -2000L; for (int i = 0; i < totalSteps; i++) { + + // ── Cancellation check at each frame boundary ────────── + if (handle.isCancelled()) { + Log.i(TAG, "Extraction cancelled at step " + i + " of " + totalSteps); + retriever.release(); + cancelExtraction(context, dialogHolder[0], callback, outputDir); + return; + } + if (i % 10 == 0) { Log.i(TAG, "Extraction step " + i + " of " + totalSteps); } @@ -221,6 +279,8 @@ public static void extractFrames(Context context, Uri videoUri, Params params, E failExtraction(context, dialogHolder[0], callback, e); } }).start(); + + return handle; } private static Bitmap scaleDownIfNeeded(Bitmap bitmap, int maxEdge) { @@ -250,7 +310,7 @@ private static void updateProgress(Context context, ProgressBar bar, TextView te private static void finishExtraction(Context context, AlertDialog dialog, ExtractionCallback callback, String msg) { if (context instanceof android.app.Activity) { ((android.app.Activity) context).runOnUiThread(() -> { - if (dialog != null) dialog.dismiss(); + if (dialog != null && dialog.isShowing()) dialog.dismiss(); Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); if (callback != null) callback.onFinished(); }); @@ -260,13 +320,31 @@ private static void finishExtraction(Context context, AlertDialog dialog, Extrac private static void failExtraction(Context context, AlertDialog dialog, ExtractionCallback callback, Exception e) { if (context instanceof android.app.Activity) { ((android.app.Activity) context).runOnUiThread(() -> { - if (dialog != null) dialog.dismiss(); + if (dialog != null && dialog.isShowing()) dialog.dismiss(); Toast.makeText(context, "Extraction failed: " + e.getMessage(), Toast.LENGTH_LONG).show(); if (callback != null) callback.onFailure(e); }); } } + /** + * Called when the user cancels extraction. Deletes any partially-written + * {@code frame_*.jpg} files but leaves the source {@code .mp4} and {@code .csv} + * files completely untouched. + */ + private static void cancelExtraction(Context context, AlertDialog dialog, ExtractionCallback callback, File outputDir) { + Log.i(TAG, "Cleaning up partial frames after cancellation"); + deleteFilesMatching(outputDir, name -> name.startsWith("frame_") && name.endsWith(".jpg")); + + if (context instanceof android.app.Activity) { + ((android.app.Activity) context).runOnUiThread(() -> { + if (dialog != null && dialog.isShowing()) dialog.dismiss(); + Toast.makeText(context, "Extraction cancelled", Toast.LENGTH_SHORT).show(); + if (callback != null) callback.onCancelled(); + }); + } + } + public static void cleanupExtractedFrames(Context context) { File outputDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES); deleteFilesMatching(outputDir, name -> name.startsWith("frame_") && name.endsWith(".jpg"));