capacitor-native-video-compressor 1.0.4 → 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 +88 -5
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +88 -5
- package/dist/docs.json +58 -0
- package/dist/esm/definitions.d.ts +35 -0
- package/dist/esm/definitions.js +6 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +16 -1
- package/dist/esm/web.js +135 -23
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +142 -23
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +142 -23
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressorPlugin.swift +112 -8
- package/package.json +1 -1
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<<a href="#cancelresult">CancelResult</a>></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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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;
|
package/dist/esm/definitions.js
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
-
await
|
|
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
|
-
|
|
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
|