capacitor-native-video-compressor 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,51 @@ try {
124
124
  listener.remove();
125
125
  ```
126
126
 
127
+ ### 3. Cancelling a Compression
128
+
129
+ Compressing a long video runs for minutes, so give the user a way out. Pass a
130
+ `jobId` when starting, then cancel with the same id:
131
+
132
+ ```typescript
133
+ import { NativeVideoCompressor, CANCELLED } from 'capacitor-native-video-compressor';
134
+
135
+ const jobId = crypto.randomUUID();
136
+
137
+ try {
138
+ const result = await NativeVideoCompressor.compressVideo({
139
+ sourcePath: '/path/to/video.mp4',
140
+ quality: 'HIGH',
141
+ jobId,
142
+ });
143
+ } catch (error) {
144
+ if (error.code === CANCELLED) {
145
+ // The user asked for this — no error toast, no retry prompt.
146
+ return;
147
+ }
148
+ throw error;
149
+ }
150
+
151
+ // From a Cancel button, anywhere:
152
+ await NativeVideoCompressor.cancel({ jobId });
153
+ ```
154
+
155
+ **Why `jobId`.** Every platform compresses one video at a time, so cancellation
156
+ is global by nature. Two independent queues in the same app (say comments and
157
+ notes) would otherwise cancel each other's work. `cancel()` with no `jobId`
158
+ cancels whatever is running, whoever started it — fine when there is only one
159
+ caller.
160
+
161
+ **What it costs.** The partial output is deleted on every platform. On web,
162
+ cancelling terminates the FFmpeg worker, because `ffmpeg.exec()` runs as
163
+ synchronous WASM inside it and never reaches a message poll while encoding —
164
+ the `AbortSignal` `exec()` accepts only rejects the main-thread promise while
165
+ the encode keeps burning CPU behind it. The loaded core is dropped with the
166
+ worker, but the core URLs are cached in module scope, so the next compress
167
+ re-instantiates without re-downloading the wasm.
168
+
169
+ `cancel()` resolves `{ cancelled: false }` when nothing was running, or when a
170
+ different job was — it never throws.
171
+
127
172
  ### Quality Presets
128
173
 
129
174
  The `quality` parameter determines the output resolution and compression ratio. Depending on the original aspect ratio, the video will be scaled to match the following heights (or widths, preserving aspect ratio):
@@ -149,6 +194,7 @@ The `quality` parameter determines the output resolution and compression ratio.
149
194
  <docgen-index>
150
195
 
151
196
  * [`compressVideo(...)`](#compressvideo)
197
+ * [`cancel(...)`](#cancel)
152
198
  * [`initialize()`](#initialize)
153
199
  * [`addListener('onProgress', ...)`](#addlisteneronprogress-)
154
200
  * [Interfaces](#interfaces)
@@ -173,6 +219,28 @@ compressVideo(options: CompressOptions) => Promise<CompressResult>
173
219
  --------------------
174
220
 
175
221
 
222
+ ### cancel(...)
223
+
224
+ ```typescript
225
+ cancel(options?: CancelOptions | undefined) => Promise<CancelResult>
226
+ ```
227
+
228
+ Stop the compress that is running now. `compressVideo` then rejects with
229
+ code {@link CANCELLED} and its partial output is deleted.
230
+
231
+ This is the only way to stop one: the work is synchronous WASM inside a
232
+ web worker (web) or an AVFoundation / LightCompressor pass on its own
233
+ thread (native), and none of them watch an AbortSignal.
234
+
235
+ | Param | Type |
236
+ | ------------- | ------------------------------------------------------- |
237
+ | **`options`** | <code><a href="#canceloptions">CancelOptions</a></code> |
238
+
239
+ **Returns:** <code>Promise&lt;<a href="#cancelresult">CancelResult</a>&gt;</code>
240
+
241
+ --------------------
242
+
243
+
176
244
  ### initialize()
177
245
 
178
246
  ```typescript
@@ -213,11 +281,26 @@ addListener(eventName: 'onProgress', listenerFunc: (info: { status: string; perc
213
281
 
214
282
  #### CompressOptions
215
283
 
216
- | Prop | Type |
217
- | ---------------- | ------------------------------------------------------------------------------- |
218
- | **`sourcePath`** | <code>string</code> |
219
- | **`destPath`** | <code>string</code> |
220
- | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> |
284
+ | Prop | Type | Description |
285
+ | ---------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | **`sourcePath`** | <code>string</code> | |
287
+ | **`destPath`** | <code>string</code> | |
288
+ | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
289
+ | **`jobId`** | <code>string</code> | Names this compress so `cancel({ jobId })` can target it. Every platform compresses one video at a time, so cancellation is global by nature. Two independent callers in the same app (say a comment queue and a notes queue) therefore need a way to say *which* job they mean — without one, a cancel from either kills whatever the other is running. |
290
+
291
+
292
+ #### CancelResult
293
+
294
+ | Prop | Type | Description |
295
+ | --------------- | -------------------- | ------------------------------------------------------------ |
296
+ | **`cancelled`** | <code>boolean</code> | False when nothing was running, or when a different job was. |
297
+
298
+
299
+ #### CancelOptions
300
+
301
+ | Prop | Type | Description |
302
+ | ----------- | ------------------- | --------------------------------------------------------------------------------------------------------- |
303
+ | **`jobId`** | <code>string</code> | Only cancel if this is the job currently running. Omit to cancel whatever is running, whoever started it. |
221
304
 
222
305
 
223
306
  #### PluginListenerHandle
@@ -22,6 +22,57 @@ import java.util.Collections;
22
22
  @CapacitorPlugin(name = "NativeVideoCompressor")
23
23
  public class NativeVideoCompressorPlugin extends Plugin {
24
24
 
25
+ /**
26
+ * Matches the `CANCELLED` constant the JS side exports, so one
27
+ * `error.code` check covers web, iOS and Android alike.
28
+ */
29
+ private static final String CANCELLED_CODE = "CANCELLED";
30
+
31
+ private static final String STORAGE_SUBFOLDER = "compressed_videos";
32
+
33
+ /** The job compressVideo is running right now, for cancel({ jobId }). */
34
+ private String currentJobId = null;
35
+
36
+ /**
37
+ * Nothing to do natively; LightCompressor needs no warm-up. Answered
38
+ * rather than left unimplemented so a caller can await it unconditionally
39
+ * (the web build does need it, and shares the call site).
40
+ */
41
+ @PluginMethod
42
+ public void initialize(PluginCall call) {
43
+ JSObject ret = new JSObject();
44
+ ret.put("success", true);
45
+ ret.put("message", "No initialization needed on Android");
46
+ call.resolve(ret);
47
+ }
48
+
49
+ /**
50
+ * Stop the running compress. LightCompressor cancels its coroutine job and
51
+ * fires onCancelled, which is already wired below.
52
+ */
53
+ @PluginMethod
54
+ public void cancel(PluginCall call) {
55
+ String requested = call.getString("jobId");
56
+ JSObject ret = new JSObject();
57
+ boolean cancelled;
58
+
59
+ // The cancel call itself must be INSIDE the lock. Released first, the
60
+ // running job could finish and a new one start in the gap — and
61
+ // VideoCompressor.cancel() being global, it would kill the newcomer.
62
+ // That is exactly what checking the jobId is meant to prevent.
63
+ // The call is non-blocking (it cancels a coroutine job), so holding
64
+ // the lock across it costs nothing.
65
+ synchronized (this) {
66
+ cancelled = currentJobId != null && (requested == null || requested.equals(currentJobId));
67
+ if (cancelled) {
68
+ VideoCompressor.cancel();
69
+ }
70
+ }
71
+
72
+ ret.put("cancelled", cancelled);
73
+ call.resolve(ret);
74
+ }
75
+
25
76
  @PluginMethod
26
77
  public void compressVideo(PluginCall call) {
27
78
  String sourcePath = call.getString("sourcePath");
@@ -31,6 +82,11 @@ public class NativeVideoCompressorPlugin extends Plugin {
31
82
  return;
32
83
  }
33
84
 
85
+ String jobId = call.getString("jobId", "default");
86
+ synchronized (this) {
87
+ currentJobId = jobId;
88
+ }
89
+
34
90
  // 1. Chuẩn bị Uri và Tên file xuất ra
35
91
  Uri srcUri;
36
92
  String fileName = "compressed_" + System.currentTimeMillis() + ".mp4";
@@ -80,7 +136,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
80
136
  );
81
137
 
82
138
  // 3. Cấu hình nơi lưu (Lưu an toàn vào thư mục nội bộ của App)
83
- AppSpecificStorageConfiguration storageConfig = new AppSpecificStorageConfiguration("compressed_videos");
139
+ AppSpecificStorageConfiguration storageConfig = new AppSpecificStorageConfiguration(STORAGE_SUBFOLDER);
84
140
 
85
141
  // Khởi động Foreground Service để giữ app luôn sống khi chạy ngầm
86
142
  Intent serviceIntent = new Intent(getContext(), VideoCompressionService.class);
@@ -114,13 +170,13 @@ public class NativeVideoCompressorPlugin extends Plugin {
114
170
 
115
171
  @Override
116
172
  public void onFailure(int i, @NonNull String failureMessage) {
117
- getContext().stopService(serviceIntent);
173
+ finishJob(serviceIntent);
118
174
  call.reject("Nén thất bại: " + failureMessage);
119
175
  }
120
176
 
121
177
  @Override
122
178
  public void onSuccess(int i, long l, @Nullable String s) {
123
- getContext().stopService(serviceIntent);
179
+ finishJob(serviceIntent);
124
180
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
125
181
  JSObject ret = new JSObject();
126
182
  ret.put("success", true);
@@ -137,10 +193,37 @@ public class NativeVideoCompressorPlugin extends Plugin {
137
193
 
138
194
  @Override
139
195
  public void onCancelled(int i) {
140
- getContext().stopService(serviceIntent);
141
- call.reject("Đã hủy nén");
196
+ finishJob(serviceIntent);
197
+ // LightCompressor leaves whatever it had already written
198
+ // behind; without this the cache keeps a partial mp4 per
199
+ // cancel, and nothing ever collects them.
200
+ deleteOutput(fileName);
201
+ // Coded so the caller can tell "the user asked for this"
202
+ // apart from a real failure — same code on every platform.
203
+ call.reject("Đã hủy nén", CANCELLED_CODE);
142
204
  }
143
205
  }
144
206
  );
145
207
  }
208
+
209
+ /** Every terminal callback ends here, so neither is ever left running. */
210
+ private void finishJob(Intent serviceIntent) {
211
+ getContext().stopService(serviceIntent);
212
+ synchronized (this) {
213
+ currentJobId = null;
214
+ }
215
+ }
216
+
217
+ /** Best-effort: a partial file left in the cache is not worth failing over. */
218
+ private void deleteOutput(String fileName) {
219
+ try {
220
+ File dir = new File(getContext().getExternalFilesDir(null), STORAGE_SUBFOLDER);
221
+ File partial = new File(dir, fileName);
222
+ if (partial.exists()) {
223
+ partial.delete();
224
+ }
225
+ } catch (Exception e) {
226
+ Log.w("NativeVideoCompressor", "Could not delete cancelled output", e);
227
+ }
228
+ }
146
229
  }
package/dist/docs.json CHANGED
@@ -24,6 +24,25 @@
24
24
  ],
25
25
  "slug": "compressvideo"
26
26
  },
27
+ {
28
+ "name": "cancel",
29
+ "signature": "(options?: CancelOptions | undefined) => Promise<CancelResult>",
30
+ "parameters": [
31
+ {
32
+ "name": "options",
33
+ "docs": "",
34
+ "type": "CancelOptions | undefined"
35
+ }
36
+ ],
37
+ "returns": "Promise<CancelResult>",
38
+ "tags": [],
39
+ "docs": "Stop the compress that is running now. `compressVideo` then rejects with\ncode {@link CANCELLED} and its partial output is deleted.\n\nThis is the only way to stop one: the work is synchronous WASM inside a\nweb worker (web) or an AVFoundation / LightCompressor pass on its own\nthread (native), and none of them watch an AbortSignal.",
40
+ "complexTypes": [
41
+ "CancelResult",
42
+ "CancelOptions"
43
+ ],
44
+ "slug": "cancel"
45
+ },
27
46
  {
28
47
  "name": "initialize",
29
48
  "signature": "() => Promise<{ success: boolean; message?: string; }>",
@@ -111,6 +130,45 @@
111
130
  "docs": "",
112
131
  "complexTypes": [],
113
132
  "type": "'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P' | undefined"
133
+ },
134
+ {
135
+ "name": "jobId",
136
+ "tags": [],
137
+ "docs": "Names this compress so `cancel({ jobId })` can target it.\n\nEvery platform compresses one video at a time, so cancellation is global\nby nature. Two independent callers in the same app (say a comment queue\nand a notes queue) therefore need a way to say *which* job they mean —\nwithout one, a cancel from either kills whatever the other is running.",
138
+ "complexTypes": [],
139
+ "type": "string | undefined"
140
+ }
141
+ ]
142
+ },
143
+ {
144
+ "name": "CancelResult",
145
+ "slug": "cancelresult",
146
+ "docs": "",
147
+ "tags": [],
148
+ "methods": [],
149
+ "properties": [
150
+ {
151
+ "name": "cancelled",
152
+ "tags": [],
153
+ "docs": "False when nothing was running, or when a different job was.",
154
+ "complexTypes": [],
155
+ "type": "boolean"
156
+ }
157
+ ]
158
+ },
159
+ {
160
+ "name": "CancelOptions",
161
+ "slug": "canceloptions",
162
+ "docs": "",
163
+ "tags": [],
164
+ "methods": [],
165
+ "properties": [
166
+ {
167
+ "name": "jobId",
168
+ "tags": [],
169
+ "docs": "Only cancel if this is the job currently running. Omit to cancel whatever\nis running, whoever started it.",
170
+ "complexTypes": [],
171
+ "type": "string | undefined"
114
172
  }
115
173
  ]
116
174
  },
@@ -1,15 +1,50 @@
1
1
  import type { PluginListenerHandle } from '@capacitor/core';
2
+ /**
3
+ * `code` on the error `compressVideo` rejects with when `cancel()` stopped it.
4
+ * Callers should treat it as "the user asked for this" — no error toast, no
5
+ * retry prompt — and tell it apart from a genuine compression failure.
6
+ */
7
+ export declare const CANCELLED = "CANCELLED";
2
8
  export interface CompressOptions {
3
9
  sourcePath: string;
4
10
  destPath?: string;
5
11
  quality?: 'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P';
12
+ /**
13
+ * Names this compress so `cancel({ jobId })` can target it.
14
+ *
15
+ * Every platform compresses one video at a time, so cancellation is global
16
+ * by nature. Two independent callers in the same app (say a comment queue
17
+ * and a notes queue) therefore need a way to say *which* job they mean —
18
+ * without one, a cancel from either kills whatever the other is running.
19
+ */
20
+ jobId?: string;
6
21
  }
7
22
  export interface CompressResult {
8
23
  success: boolean;
9
24
  destPath: string;
10
25
  }
26
+ export interface CancelOptions {
27
+ /**
28
+ * Only cancel if this is the job currently running. Omit to cancel whatever
29
+ * is running, whoever started it.
30
+ */
31
+ jobId?: string;
32
+ }
33
+ export interface CancelResult {
34
+ /** False when nothing was running, or when a different job was. */
35
+ cancelled: boolean;
36
+ }
11
37
  export interface NativeVideoCompressorPlugin {
12
38
  compressVideo(options: CompressOptions): Promise<CompressResult>;
39
+ /**
40
+ * Stop the compress that is running now. `compressVideo` then rejects with
41
+ * code {@link CANCELLED} and its partial output is deleted.
42
+ *
43
+ * This is the only way to stop one: the work is synchronous WASM inside a
44
+ * web worker (web) or an AVFoundation / LightCompressor pass on its own
45
+ * thread (native), and none of them watch an AbortSignal.
46
+ */
47
+ cancel(options?: CancelOptions): Promise<CancelResult>;
13
48
  initialize(): Promise<{
14
49
  success: boolean;
15
50
  message?: string;
@@ -1,2 +1,7 @@
1
- export {};
1
+ /**
2
+ * `code` on the error `compressVideo` rejects with when `cancel()` stopped it.
3
+ * Callers should treat it as "the user asked for this" — no error toast, no
4
+ * retry prompt — and tell it apart from a genuine compression failure.
5
+ */
6
+ export const CANCELLED = 'CANCELLED';
2
7
  //# sourceMappingURL=definitions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\nexport interface CompressOptions {\n sourcePath: string;\n destPath?: string;\n quality?: 'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P';\n}\n\nexport interface CompressResult {\n success: boolean;\n destPath: string;\n}\n\nexport interface NativeVideoCompressorPlugin {\n compressVideo(options: CompressOptions): Promise<CompressResult>;\n\n initialize(): Promise<{ success: boolean; message?: string }>;\n\n // Đã sửa lại chuẩn cho Capacitor mới nhất (chỉ trả về Promise)\n addListener(\n eventName: 'onProgress',\n listenerFunc: (info: { status: string; percent?: number }) => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAC","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * `code` on the error `compressVideo` rejects with when `cancel()` stopped it.\n * Callers should treat it as \"the user asked for this\" — no error toast, no\n * retry prompt — and tell it apart from a genuine compression failure.\n */\nexport const CANCELLED = 'CANCELLED';\n\nexport interface CompressOptions {\n sourcePath: string;\n destPath?: string;\n quality?: 'VERY_HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY_LOW' | '360P';\n /**\n * Names this compress so `cancel({ jobId })` can target it.\n *\n * Every platform compresses one video at a time, so cancellation is global\n * by nature. Two independent callers in the same app (say a comment queue\n * and a notes queue) therefore need a way to say *which* job they mean —\n * without one, a cancel from either kills whatever the other is running.\n */\n jobId?: string;\n}\n\nexport interface CompressResult {\n success: boolean;\n destPath: string;\n}\n\nexport interface CancelOptions {\n /**\n * Only cancel if this is the job currently running. Omit to cancel whatever\n * is running, whoever started it.\n */\n jobId?: string;\n}\n\nexport interface CancelResult {\n /** False when nothing was running, or when a different job was. */\n cancelled: boolean;\n}\n\nexport interface NativeVideoCompressorPlugin {\n compressVideo(options: CompressOptions): Promise<CompressResult>;\n\n /**\n * Stop the compress that is running now. `compressVideo` then rejects with\n * code {@link CANCELLED} and its partial output is deleted.\n *\n * This is the only way to stop one: the work is synchronous WASM inside a\n * web worker (web) or an AVFoundation / LightCompressor pass on its own\n * thread (native), and none of them watch an AbortSignal.\n */\n cancel(options?: CancelOptions): Promise<CancelResult>;\n\n initialize(): Promise<{ success: boolean; message?: string }>;\n\n // Đã sửa lại chuẩn cho Capacitor mới nhất (chỉ trả về Promise)\n addListener(\n eventName: 'onProgress',\n listenerFunc: (info: { status: string; percent?: number }) => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
package/dist/esm/web.d.ts CHANGED
@@ -1,9 +1,24 @@
1
1
  import { WebPlugin } from '@capacitor/core';
2
- import type { NativeVideoCompressorPlugin, CompressOptions, CompressResult } from './definitions';
2
+ import type { NativeVideoCompressorPlugin, CompressOptions, CompressResult, CancelOptions, CancelResult } from './definitions';
3
3
  export declare class NativeVideoCompressorWeb extends WebPlugin implements NativeVideoCompressorPlugin {
4
4
  private ffmpeg;
5
5
  private isLoaded;
6
+ /** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
7
+ private currentJobId;
8
+ private cancelRequested;
6
9
  private loadFFmpeg;
10
+ /**
11
+ * Kill the worker outright — there is no gentler option.
12
+ *
13
+ * `ffmpeg.exec()` runs *synchronously* inside the worker (see
14
+ * @ffmpeg/ffmpeg's worker.js: `ffmpeg.exec(...args)` is not awaited), so the
15
+ * worker never reaches a message poll while encoding. The AbortSignal that
16
+ * `exec()` accepts only rejects the main-thread promise; the encode keeps
17
+ * burning CPU behind it. `worker.terminate()` is what actually stops it.
18
+ *
19
+ * The cost is the loaded core, which is why the core URLs are cached above.
20
+ */
21
+ cancel(options?: CancelOptions): Promise<CancelResult>;
7
22
  initialize(): Promise<{
8
23
  success: boolean;
9
24
  message?: string;
package/dist/esm/web.js CHANGED
@@ -1,41 +1,116 @@
1
1
  import { WebPlugin } from '@capacitor/core';
2
2
  import { FFmpeg } from '@ffmpeg/ffmpeg';
3
3
  import { fetchFile, toBlobURL } from '@ffmpeg/util';
4
+ import { CANCELLED } from './definitions';
5
+ /**
6
+ * Rejection shape for a cancelled compress. `code` matches what `call.reject`
7
+ * puts on the error object on iOS/Android, so one `error.code === 'CANCELLED'`
8
+ * check works on every platform.
9
+ */
10
+ class CancelledError extends Error {
11
+ constructor() {
12
+ super('Đã hủy nén');
13
+ this.code = CANCELLED;
14
+ this.name = 'CancelledError';
15
+ }
16
+ }
17
+ const CORE_BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
18
+ /**
19
+ * Cancelling means terminating the worker, which drops the loaded core with
20
+ * it — so without this the next compress re-downloads ~30 MB of wasm and
21
+ * "cancel" quietly costs more than it saves. Module scope, not instance: the
22
+ * bytes are identical for every instance and every job.
23
+ */
24
+ let corePromise = null;
25
+ const loadCoreURLs = () => {
26
+ if (!corePromise) {
27
+ corePromise = Promise.all([
28
+ toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, 'text/javascript'),
29
+ toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, 'application/wasm'),
30
+ ])
31
+ .then(([coreURL, wasmURL]) => ({ coreURL, wasmURL }))
32
+ .catch((e) => {
33
+ // Don't cache a failure — a flaky network shouldn't make every later
34
+ // compress in this session fail too.
35
+ corePromise = null;
36
+ throw e;
37
+ });
38
+ }
39
+ return corePromise;
40
+ };
4
41
  export class NativeVideoCompressorWeb extends WebPlugin {
5
42
  constructor() {
6
43
  super(...arguments);
7
44
  this.ffmpeg = null;
8
45
  this.isLoaded = false;
46
+ /** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
47
+ this.currentJobId = null;
48
+ this.cancelRequested = false;
9
49
  }
10
50
  async loadFFmpeg() {
11
51
  if (this.isLoaded && this.ffmpeg)
12
52
  return;
13
53
  this.notifyListeners('onProgress', { status: 'loading_core', percent: 0 });
54
+ // Built on a local, published to the field only once it is ready and still
55
+ // wanted. Assigning `this.ffmpeg` up front and setting `isLoaded` after the
56
+ // await left a window where a cancel could null the instance and the load,
57
+ // finishing a moment later, would set `isLoaded = true` on nothing at all —
58
+ // after which every compressVideo threw 'FFmpeg failed to load' for the
59
+ // rest of the page's life.
60
+ const instance = new FFmpeg();
61
+ // Listen to FFmpeg progress and map it to capacitor plugin events
62
+ instance.on('progress', ({ progress }) => {
63
+ // progress is a fraction between 0 and 1
64
+ this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
65
+ });
66
+ instance.on('log', ({ message }) => {
67
+ console.log('[FFmpeg Log]', message);
68
+ });
14
69
  try {
15
- this.ffmpeg = new FFmpeg();
16
- // Listen to FFmpeg progress and map it to capacitor plugin events
17
- this.ffmpeg.on('progress', ({ progress }) => {
18
- // progress is a fraction between 0 and 1
19
- this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
20
- });
21
- this.ffmpeg.on('log', ({ message }) => {
22
- console.log('[FFmpeg Log]', message);
23
- });
24
70
  // Use standard single-threaded version for best compatibility across browsers
25
71
  // without requiring complex SharedArrayBuffer headers configuration
26
- const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
27
- console.log('Starting to load FFmpeg core from:', baseURL);
28
- await this.ffmpeg.load({
29
- coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
30
- wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
31
- });
32
- this.isLoaded = true;
33
- console.log('FFmpeg core loaded successfully');
72
+ console.log('Starting to load FFmpeg core from:', CORE_BASE_URL);
73
+ await instance.load(await loadCoreURLs());
34
74
  }
35
75
  catch (e) {
76
+ instance.terminate();
36
77
  console.error('Failed to load FFmpeg:', e);
37
78
  throw e;
38
79
  }
80
+ // Cancelled while this was loading. Terminate what we just built rather
81
+ // than publish it — the caller asked for none of it.
82
+ if (this.cancelRequested) {
83
+ instance.terminate();
84
+ throw new CancelledError();
85
+ }
86
+ this.ffmpeg = instance;
87
+ this.isLoaded = true;
88
+ console.log('FFmpeg core loaded successfully');
89
+ }
90
+ /**
91
+ * Kill the worker outright — there is no gentler option.
92
+ *
93
+ * `ffmpeg.exec()` runs *synchronously* inside the worker (see
94
+ * @ffmpeg/ffmpeg's worker.js: `ffmpeg.exec(...args)` is not awaited), so the
95
+ * worker never reaches a message poll while encoding. The AbortSignal that
96
+ * `exec()` accepts only rejects the main-thread promise; the encode keeps
97
+ * burning CPU behind it. `worker.terminate()` is what actually stops it.
98
+ *
99
+ * The cost is the loaded core, which is why the core URLs are cached above.
100
+ */
101
+ async cancel(options) {
102
+ var _a;
103
+ if (!this.currentJobId)
104
+ return { cancelled: false };
105
+ if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJobId)
106
+ return { cancelled: false };
107
+ // Set even when there is no instance yet: the job may still be inside
108
+ // loadFFmpeg, which reads this flag before publishing what it built.
109
+ this.cancelRequested = true;
110
+ (_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
111
+ this.ffmpeg = null;
112
+ this.isLoaded = false;
113
+ return { cancelled: true };
39
114
  }
40
115
  async initialize() {
41
116
  try {
@@ -50,13 +125,30 @@ export class NativeVideoCompressorWeb extends WebPlugin {
50
125
  }
51
126
  }
52
127
  async compressVideo(options) {
128
+ var _a;
129
+ this.currentJobId = (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default';
130
+ this.cancelRequested = false;
131
+ // Checked at every stage boundary, not just around exec: terminate() only
132
+ // reaches work that has already got to the worker, so a cancel during the
133
+ // core download or the source fetch has nothing to interrupt and would
134
+ // otherwise let the compress carry on as if nothing had happened.
135
+ const throwIfCancelled = () => {
136
+ if (this.cancelRequested)
137
+ throw new CancelledError();
138
+ };
53
139
  try {
54
140
  if (!this.isLoaded) {
55
141
  await this.loadFFmpeg();
56
142
  }
143
+ throwIfCancelled();
57
144
  if (!this.ffmpeg) {
58
145
  throw new Error('FFmpeg failed to load');
59
146
  }
147
+ // Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after
148
+ // any of the four awaits below can hand back null on a reference the
149
+ // compiler narrowed at this line — surfacing a cancel as a TypeError
150
+ // instead of a CancelledError.
151
+ const ffmpeg = this.ffmpeg;
60
152
  this.notifyListeners('onProgress', { status: 'started', percent: 0 });
61
153
  const { sourcePath, quality = 'MEDIUM' } = options;
62
154
  const inputFileName = 'input.mp4';
@@ -106,14 +198,18 @@ export class NativeVideoCompressorWeb extends WebPlugin {
106
198
  console.error('[FFmpeg] Error fetching source file:', err);
107
199
  throw new Error('Could not fetch source video file');
108
200
  }
201
+ throwIfCancelled();
109
202
  console.log('[FFmpeg] Writing file to FS...', inputData.byteLength, 'bytes');
110
203
  try {
111
- await this.ffmpeg.writeFile(inputFileName, inputData);
204
+ await ffmpeg.writeFile(inputFileName, inputData);
112
205
  }
113
206
  catch (err) {
207
+ if (this.cancelRequested)
208
+ throw new CancelledError();
114
209
  console.error('[FFmpeg] Error writing to FS:', err);
115
210
  throw new Error('Could not write to FFmpeg FS');
116
211
  }
212
+ throwIfCancelled();
117
213
  // Construct FFmpeg command array
118
214
  const args = ['-i', inputFileName];
119
215
  // Video codec and compression parameters (superfast provides much better size than ultrafast)
@@ -128,25 +224,32 @@ export class NativeVideoCompressorWeb extends WebPlugin {
128
224
  args.push(outputFileName);
129
225
  console.log('[FFmpeg] Executing command:', args.join(' '));
130
226
  try {
131
- const retCode = await this.ffmpeg.exec(args);
227
+ const retCode = await ffmpeg.exec(args);
132
228
  console.log('[FFmpeg] Exec returned code:', retCode);
133
229
  }
134
230
  catch (err) {
231
+ // terminate() rejects every in-flight call, so a cancel arrives here
232
+ // looking like a generic failure unless we name it.
233
+ if (this.cancelRequested)
234
+ throw new CancelledError();
135
235
  console.error('[FFmpeg] Error executing command:', err);
136
236
  throw new Error('FFmpeg execution failed');
137
237
  }
238
+ throwIfCancelled();
138
239
  console.log('[FFmpeg] Reading output file...');
139
240
  let data;
140
241
  try {
141
- data = await this.ffmpeg.readFile(outputFileName);
242
+ data = await ffmpeg.readFile(outputFileName);
142
243
  }
143
244
  catch (err) {
245
+ if (this.cancelRequested)
246
+ throw new CancelledError();
144
247
  console.error('[FFmpeg] Error reading output file:', err);
145
248
  throw new Error('Could not read FFmpeg output');
146
249
  }
147
250
  // Free memory
148
- await this.ffmpeg.deleteFile(inputFileName);
149
- await this.ffmpeg.deleteFile(outputFileName);
251
+ await ffmpeg.deleteFile(inputFileName);
252
+ await ffmpeg.deleteFile(outputFileName);
150
253
  console.log('[FFmpeg] Creating Blob URL from output...', data.byteLength, 'bytes');
151
254
  // Create a blob URL for the resulting file
152
255
  const blob = new Blob([data], { type: 'video/mp4' });
@@ -158,9 +261,18 @@ export class NativeVideoCompressorWeb extends WebPlugin {
158
261
  };
159
262
  }
160
263
  catch (error) {
161
- console.error('[FFmpeg] Compression process failed:', error);
264
+ if ((error === null || error === void 0 ? void 0 : error.code) === CANCELLED) {
265
+ console.log('[FFmpeg] Compression cancelled');
266
+ }
267
+ else {
268
+ console.error('[FFmpeg] Compression process failed:', error);
269
+ }
162
270
  throw error;
163
271
  }
272
+ finally {
273
+ this.currentJobId = null;
274
+ this.cancelRequested = false;
275
+ }
164
276
  }
165
277
  }
166
278
  //# sourceMappingURL=web.js.map