Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -506,15 +512,22 @@ 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));
}

@Override
public void onFinished() {
extractionHandle = null;
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Frames extracted!", Toast.LENGTH_SHORT).show());
notifyPlatformEvent("extraction_complete", "");
if (telemetryMode) {
Expand All @@ -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");
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
*
* <p>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.</p>
*/
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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
});
Expand All @@ -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"));
Expand Down