capacitor-native-video-compressor 1.0.4 → 1.1.0

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,12 +1,33 @@
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
- private loadFFmpeg;
6
+ /** The mediabunny conversion running now, so `cancel()` can abort it. */
7
+ private activeConversion;
8
+ /** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
9
+ private currentJobId;
10
+ private cancelRequested;
7
11
  initialize(): Promise<{
8
12
  success: boolean;
9
13
  message?: string;
10
14
  }>;
11
15
  compressVideo(options: CompressOptions): Promise<CompressResult>;
16
+ /**
17
+ * Kill whichever engine is mid-compress. Only one is ever active at a time,
18
+ * so acting on both is safe.
19
+ *
20
+ * WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
21
+ * with `ConversionCanceledError`, surfaced as a `CancelledError` below.
22
+ * ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
23
+ * message poll while encoding, so `worker.terminate()` is the only way to
24
+ * stop it — its cost is the loaded core, which is why the core URLs are
25
+ * cached above.
26
+ */
27
+ cancel(options?: CancelOptions): Promise<CancelResult>;
28
+ private fetchSourceBlob;
29
+ private canUseWebCodecs;
30
+ private compressWithWebCodecs;
31
+ private loadFFmpeg;
32
+ private compressWithFfmpeg;
12
33
  }