capacitor-native-video-compressor 1.2.0 → 2.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.
@@ -0,0 +1,134 @@
1
+ package com.atomic.videocompressor;
2
+
3
+ import android.content.Context;
4
+ import androidx.annotation.NonNull;
5
+ import androidx.annotation.Nullable;
6
+ import androidx.work.Worker;
7
+ import androidx.work.WorkerParameters;
8
+ import java.util.Map;
9
+ import java.util.concurrent.ConcurrentHashMap;
10
+
11
+ /**
12
+ * Giữ pha nén chạy được khi user rời app, không cần foreground service.
13
+ *
14
+ * Android 14+ đóng băng process ở trạng thái cached, nên nén bằng một thread
15
+ * thường sẽ đứng lại ngay khi user chuyển app. Foreground service từng là cách
16
+ * giữ nó sống, nhưng Google Play từ chối version code 32: use case này được xếp
17
+ * vào data sync + local processing, không đủ điều kiện cho
18
+ * FOREGROUND_SERVICE_MEDIA_PROCESSING, và họ chỉ đích danh WorkManager làm
19
+ * phương án thay thế. Process đang chạy một worker thì không bị đóng băng.
20
+ *
21
+ * Worker chỉ là vỏ bọc: việc nén thật do plugin đăng ký qua {@link #register}
22
+ * ngay trước khi enqueue, vì chỉ plugin mới giữ được PluginCall để trả kết quả.
23
+ *
24
+ * Với `holdForUpload`, worker ở lại sau khi nén xong cho tới khi caller gọi
25
+ * `finishUpload`. Pha upload chạy bằng JS trong webview, và khoảnh khắc worker
26
+ * kết thúc là khoảnh khắc process thành cached rồi bị đóng băng - đo trên
27
+ * Android 16: upload chết đúng ~5 giây sau khi worker trả về. Giữ worker sống
28
+ * xuyên pha upload là cách duy nhất còn lại để nó sống sót, vì foreground
29
+ * service đã bị Play cấm cho use case này.
30
+ *
31
+ * Giới hạn phải biết: WorkManager dừng worker sau 10 phút, tính chung cả nén
32
+ * lẫn upload. Quá mức đó {@link #onStopped()} huỷ việc đang chạy và caller
33
+ * nhận lỗi CANCELLED - đổi lại lấy được nhiều thời gian hơn thì phải có
34
+ * foreground service, thứ không còn được phép ở đây.
35
+ */
36
+ public class VideoCompressionWorker extends Worker {
37
+
38
+ static final String KEY_JOB_ID = "jobId";
39
+
40
+ /**
41
+ * Việc nén do plugin đăng ký, tra theo jobId.
42
+ *
43
+ * Static vì worker được WorkManager dựng mới, không nhận được tham chiếu
44
+ * nào từ phía plugin. Map chỉ có nghĩa trong phạm vi một process: process
45
+ * chết là WebView chết, không còn ai nhận kết quả nén nữa.
46
+ */
47
+ private static final Map<String, Job> pending = new ConcurrentHashMap<>();
48
+
49
+ interface Job {
50
+ /**
51
+ * Chạy pha nén, và với holdForUpload thì ở lại tới khi caller gọi
52
+ * finishUpload. Chỉ trả về khi không còn gì phải giữ process sống.
53
+ */
54
+ void run();
55
+
56
+ /** WorkManager đòi dừng - hết 10 phút, hoặc work bị cancel. */
57
+ void stop();
58
+
59
+ /** Caller báo đã upload xong; worker được phép kết thúc. */
60
+ void release();
61
+ }
62
+
63
+ static void register(String jobId, Job job) {
64
+ pending.put(jobId, job);
65
+ }
66
+
67
+ static void unregister(String jobId) {
68
+ pending.remove(jobId);
69
+ }
70
+
71
+ /**
72
+ * Thả worker đang giữ process sống cho pha upload của jobId.
73
+ *
74
+ * False khi không còn job nào mang id đó - worker đã kết thúc từ trước,
75
+ * không có gì để thả. Đó là trạng thái bình thường, không phải lỗi.
76
+ */
77
+ static boolean release(String jobId) {
78
+ Job job = pending.get(jobId);
79
+ if (job == null) return false;
80
+ job.release();
81
+ return true;
82
+ }
83
+
84
+ /** Dừng và gỡ mọi job đang đăng ký - chúng thuộc một phiên WebView đã chết. */
85
+ static void stopAll() {
86
+ for (String jobId : pending.keySet()) stop(jobId);
87
+ }
88
+
89
+ /** Dừng và gỡ job của jobId; false khi không còn job nào mang id đó. */
90
+ static boolean stop(String jobId) {
91
+ Job job = pending.remove(jobId);
92
+ if (job == null) return false;
93
+ job.stop();
94
+ return true;
95
+ }
96
+
97
+ @Nullable
98
+ private volatile Job running = null;
99
+
100
+ public VideoCompressionWorker(@NonNull Context context, @NonNull WorkerParameters params) {
101
+ super(context, params);
102
+ }
103
+
104
+ @NonNull
105
+ @Override
106
+ public Result doWork() {
107
+ String jobId = getInputData().getString(KEY_JOB_ID);
108
+ if (jobId == null) return Result.success();
109
+
110
+ Job job = pending.get(jobId);
111
+ // Không còn job: process đã bị kill và WorkManager dựng lại worker ở
112
+ // process mới. WebView đã chết cùng nó nên không còn ai nhận kết quả -
113
+ // chạy lại pha nén chỉ tốn pin. Success chứ không retry, để WorkManager
114
+ // bỏ hẳn work này đi.
115
+ if (job == null) return Result.success();
116
+
117
+ running = job;
118
+ try {
119
+ job.run();
120
+ } finally {
121
+ running = null;
122
+ // Hai tham số: một phiên mới có thể đã đăng ký lại cùng jobId.
123
+ pending.remove(jobId, job);
124
+ }
125
+ return Result.success();
126
+ }
127
+
128
+ @Override
129
+ public void onStopped() {
130
+ Job job = running;
131
+ if (job != null) job.stop();
132
+ super.onStopped();
133
+ }
134
+ }
package/dist/docs.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "returns": "Promise<CompressResult>",
19
19
  "tags": [],
20
- "docs": "",
20
+ "docs": "Compress one video and resolve with the path of the result.\n\nAndroid: the work runs inside a WorkManager worker, which keeps the\nprocess out of the frozen cached state while the user is away — there is\nno foreground service and no notification, because Google Play classes\nthis use case as data sync plus local processing and rejects\n`FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker\nafter 10 minutes, so a compress that runs longer rejects with\n{@link CANCELLED}.",
21
21
  "complexTypes": [
22
22
  "CompressResult",
23
23
  "CompressOptions"
@@ -43,6 +43,22 @@
43
43
  ],
44
44
  "slug": "cancel"
45
45
  },
46
+ {
47
+ "name": "finishUpload",
48
+ "signature": "(options: { jobId: string; }) => Promise<{ released: boolean; }>",
49
+ "parameters": [
50
+ {
51
+ "name": "options",
52
+ "docs": "",
53
+ "type": "{ jobId: string; }"
54
+ }
55
+ ],
56
+ "returns": "Promise<{ released: boolean; }>",
57
+ "tags": [],
58
+ "docs": "Release the worker that `holdForUpload` kept alive for this job.\n\nCall it on every terminal branch of the upload, success or failure. A\nmissed call leaves the device feeding an idle job until the hold expires.\n`released` is false when no job by that id was still being held, which is\nthe normal outcome of a late or duplicate call, not an error.\n\nWeb: deletes the compressed output instead, so call it only once the File\nis no longer needed (not before a retry).",
59
+ "complexTypes": [],
60
+ "slug": "finishupload"
61
+ },
46
62
  {
47
63
  "name": "initialize",
48
64
  "signature": "() => Promise<{ success: boolean; message?: string; }>",
@@ -75,29 +91,6 @@
75
91
  "PluginListenerHandle"
76
92
  ],
77
93
  "slug": "addlisteneronprogress-"
78
- },
79
- {
80
- "name": "addListener",
81
- "signature": "(eventName: 'onCancelRequested', listenerFunc: () => void) => Promise<PluginListenerHandle>",
82
- "parameters": [
83
- {
84
- "name": "eventName",
85
- "docs": "",
86
- "type": "'onCancelRequested'"
87
- },
88
- {
89
- "name": "listenerFunc",
90
- "docs": "",
91
- "type": "() => void"
92
- }
93
- ],
94
- "returns": "Promise<PluginListenerHandle>",
95
- "tags": [],
96
- "docs": "Android: user đã bấm \"Huỷ\" trên notification của foreground service.\n\nPha nén được plugin tự huỷ trước khi event này bắn, nhưng việc caller định\nlàm với kết quả nén - upload chẳng hạn - thì không ai dừng hộ được, nên\ncaller phải tự abort. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ,\nvà Play đánh trượt tiêu chí \"can be terminated or stopped by the user\".",
97
- "complexTypes": [
98
- "PluginListenerHandle"
99
- ],
100
- "slug": "addlisteneroncancelrequested-"
101
94
  }
102
95
  ],
103
96
  "properties": []
@@ -160,6 +153,13 @@
160
153
  "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.",
161
154
  "complexTypes": [],
162
155
  "type": "string | undefined"
156
+ },
157
+ {
158
+ "name": "holdForUpload",
159
+ "tags": [],
160
+ "docs": "Android only: keep the background worker alive after the compress ends,\nuntil `finishUpload({ jobId })` is called.\n\nThe upload runs as JavaScript in the webview, and the moment the worker\nreturns the process becomes cached and is frozen — measured on Android 16,\nan in-flight upload dies about five seconds later. Set this whenever the\ncompressed file is about to be uploaded, and always pair it with a\n`finishUpload` in a `finally`.",
161
+ "complexTypes": [],
162
+ "type": "boolean | undefined"
163
163
  }
164
164
  ]
165
165
  },
@@ -5,6 +5,20 @@ import type { PluginListenerHandle } from '@capacitor/core';
5
5
  * retry prompt — and tell it apart from a genuine compression failure.
6
6
  */
7
7
  export declare const CANCELLED = "CANCELLED";
8
+ /**
9
+ * `code` on the error `compressVideo` rejects with on web when WebCodecs can't
10
+ * encode the video and it is larger than 200 MB, the cap for the ffmpeg.wasm
11
+ * fallback. ffmpeg.wasm copies the whole input into wasm memory (~2 GB
12
+ * ceiling), so a large file there crashes or hangs the tab rather than failing
13
+ * cleanly.
14
+ */
15
+ export declare const TOO_LARGE_FOR_WEB = "TOO_LARGE_FOR_WEB";
16
+ /**
17
+ * `code` on the error `compressVideo` rejects with on web when the browser
18
+ * refuses to store more of the output (storage quota). Retryable once space
19
+ * is freed.
20
+ */
21
+ export declare const STORAGE_FULL = "STORAGE_FULL";
8
22
  export interface CompressOptions {
9
23
  sourcePath: string;
10
24
  destPath?: string;
@@ -18,6 +32,17 @@ export interface CompressOptions {
18
32
  * without one, a cancel from either kills whatever the other is running.
19
33
  */
20
34
  jobId?: string;
35
+ /**
36
+ * Android only: keep the background worker alive after the compress ends,
37
+ * until `finishUpload({ jobId })` is called.
38
+ *
39
+ * The upload runs as JavaScript in the webview, and the moment the worker
40
+ * returns the process becomes cached and is frozen — measured on Android 16,
41
+ * an in-flight upload dies about five seconds later. Set this whenever the
42
+ * compressed file is about to be uploaded, and always pair it with a
43
+ * `finishUpload` in a `finally`.
44
+ */
45
+ holdForUpload?: boolean;
21
46
  }
22
47
  export interface CompressResult {
23
48
  success: boolean;
@@ -35,6 +60,17 @@ export interface CancelResult {
35
60
  cancelled: boolean;
36
61
  }
37
62
  export interface NativeVideoCompressorPlugin {
63
+ /**
64
+ * Compress one video and resolve with the path of the result.
65
+ *
66
+ * Android: the work runs inside a WorkManager worker, which keeps the
67
+ * process out of the frozen cached state while the user is away — there is
68
+ * no foreground service and no notification, because Google Play classes
69
+ * this use case as data sync plus local processing and rejects
70
+ * `FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker
71
+ * after 10 minutes, so a compress that runs longer rejects with
72
+ * {@link CANCELLED}.
73
+ */
38
74
  compressVideo(options: CompressOptions): Promise<CompressResult>;
39
75
  /**
40
76
  * Stop the compress that is running now. `compressVideo` then rejects with
@@ -45,6 +81,22 @@ export interface NativeVideoCompressorPlugin {
45
81
  * thread (native), and none of them watch an AbortSignal.
46
82
  */
47
83
  cancel(options?: CancelOptions): Promise<CancelResult>;
84
+ /**
85
+ * Release the worker that `holdForUpload` kept alive for this job.
86
+ *
87
+ * Call it on every terminal branch of the upload, success or failure. A
88
+ * missed call leaves the device feeding an idle job until the hold expires.
89
+ * `released` is false when no job by that id was still being held, which is
90
+ * the normal outcome of a late or duplicate call, not an error.
91
+ *
92
+ * Web: deletes the compressed output instead, so call it only once the File
93
+ * is no longer needed (not before a retry).
94
+ */
95
+ finishUpload(options: {
96
+ jobId: string;
97
+ }): Promise<{
98
+ released: boolean;
99
+ }>;
48
100
  initialize(): Promise<{
49
101
  success: boolean;
50
102
  message?: string;
@@ -53,13 +105,4 @@ export interface NativeVideoCompressorPlugin {
53
105
  status: string;
54
106
  percent?: number;
55
107
  }) => void): Promise<PluginListenerHandle>;
56
- /**
57
- * Android: user đã bấm "Huỷ" trên notification của foreground service.
58
- *
59
- * Pha nén được plugin tự huỷ trước khi event này bắn, nhưng việc caller định
60
- * làm với kết quả nén - upload chẳng hạn - thì không ai dừng hộ được, nên
61
- * caller phải tự abort. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ,
62
- * và Play đánh trượt tiêu chí "can be terminated or stopped by the user".
63
- */
64
- addListener(eventName: 'onCancelRequested', listenerFunc: () => void): Promise<PluginListenerHandle>;
65
108
  }
@@ -4,4 +4,18 @@
4
4
  * retry prompt — and tell it apart from a genuine compression failure.
5
5
  */
6
6
  export const CANCELLED = 'CANCELLED';
7
+ /**
8
+ * `code` on the error `compressVideo` rejects with on web when WebCodecs can't
9
+ * encode the video and it is larger than 200 MB, the cap for the ffmpeg.wasm
10
+ * fallback. ffmpeg.wasm copies the whole input into wasm memory (~2 GB
11
+ * ceiling), so a large file there crashes or hangs the tab rather than failing
12
+ * cleanly.
13
+ */
14
+ export const TOO_LARGE_FOR_WEB = 'TOO_LARGE_FOR_WEB';
15
+ /**
16
+ * `code` on the error `compressVideo` rejects with on web when the browser
17
+ * refuses to store more of the output (storage quota). Retryable once space
18
+ * is freed.
19
+ */
20
+ export const STORAGE_FULL = 'STORAGE_FULL';
7
21
  //# sourceMappingURL=definitions.js.map
@@ -1 +1 @@
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 /**\n * Android: user đã bấm \"Huỷ\" trên notification của foreground service.\n *\n * Pha nén được plugin tự huỷ trước khi event này bắn, nhưng việc caller định\n * làm với kết quả nén - upload chẳng hạn - thì không ai dừng hộ được, nên\n * caller phải tự abort. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ,\n * và Play đánh trượt tiêu chí \"can be terminated or stopped by the user\".\n */\n addListener(\n eventName: 'onCancelRequested',\n listenerFunc: () => 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;AAErC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,mBAAmB,CAAC;AAErD;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,cAAc,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\n/**\n * `code` on the error `compressVideo` rejects with on web when WebCodecs can't\n * encode the video and it is larger than 200 MB, the cap for the ffmpeg.wasm\n * fallback. ffmpeg.wasm copies the whole input into wasm memory (~2 GB\n * ceiling), so a large file there crashes or hangs the tab rather than failing\n * cleanly.\n */\nexport const TOO_LARGE_FOR_WEB = 'TOO_LARGE_FOR_WEB';\n\n/**\n * `code` on the error `compressVideo` rejects with on web when the browser\n * refuses to store more of the output (storage quota). Retryable once space\n * is freed.\n */\nexport const STORAGE_FULL = 'STORAGE_FULL';\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 * Android only: keep the background worker alive after the compress ends,\n * until `finishUpload({ jobId })` is called.\n *\n * The upload runs as JavaScript in the webview, and the moment the worker\n * returns the process becomes cached and is frozen — measured on Android 16,\n * an in-flight upload dies about five seconds later. Set this whenever the\n * compressed file is about to be uploaded, and always pair it with a\n * `finishUpload` in a `finally`.\n */\n holdForUpload?: boolean;\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 /**\n * Compress one video and resolve with the path of the result.\n *\n * Android: the work runs inside a WorkManager worker, which keeps the\n * process out of the frozen cached state while the user is away — there is\n * no foreground service and no notification, because Google Play classes\n * this use case as data sync plus local processing and rejects\n * `FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker\n * after 10 minutes, so a compress that runs longer rejects with\n * {@link CANCELLED}.\n */\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 /**\n * Release the worker that `holdForUpload` kept alive for this job.\n *\n * Call it on every terminal branch of the upload, success or failure. A\n * missed call leaves the device feeding an idle job until the hold expires.\n * `released` is false when no job by that id was still being held, which is\n * the normal outcome of a late or duplicate call, not an error.\n *\n * Web: deletes the compressed output instead, so call it only once the File\n * is no longer needed (not before a retry).\n */\n finishUpload(options: { jobId: string }): Promise<{ released: boolean }>;\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"]}
@@ -0,0 +1,7 @@
1
+ export interface OpfsOutput {
2
+ writable: FileSystemWritableFileStream;
3
+ read: () => Promise<Blob>;
4
+ discard: () => Promise<void>;
5
+ }
6
+ /** Null when OPFS or `createWritable` (missing on Safari's main thread) isn't there. */
7
+ export declare const createOpfsOutput: () => Promise<OpfsOutput | null>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * WebCodecs output goes to the origin private file system so a long video
3
+ * never sits whole in memory. The File handed back stays readable only while
4
+ * its OPFS entry exists, and callers keep it well past `compressVideo` (until
5
+ * the upload is done, or longer for a retry), so a successful output stays
6
+ * until the caller releases it with `finishUpload({ jobId })` or the tab
7
+ * closes. Each tab writes under its own directory and holds a Web Lock named
8
+ * after it for its whole life; every compress removes the directories whose
9
+ * lock nobody holds any more, i.e. those of closed tabs. A failed or cancelled
10
+ * job deletes its own file straight away.
11
+ */
12
+ const OPFS_ROOT = 'native-video-compressor';
13
+ const lockName = (tabId) => `${OPFS_ROOT}:${tabId}`;
14
+ let tabId = null;
15
+ const getTabId = () => (tabId !== null && tabId !== void 0 ? tabId : (tabId = crypto.randomUUID()));
16
+ let fileCount = 0;
17
+ // Memoised apart from the directories: they are reopened after site data is
18
+ // cleared, but this tab still holds its lock, and requesting it again would
19
+ // wait forever on ourselves.
20
+ let tabLock = null;
21
+ let tabDirsPromise = null;
22
+ // A rejected request (e.g. a sandboxed iframe) rejects here, so openTabDirs
23
+ // fails and the caller buffers in memory instead of waiting forever.
24
+ const holdTabLock = () => new Promise((granted, rejected) => {
25
+ navigator.locks
26
+ .request(lockName(getTabId()), () => {
27
+ granted();
28
+ return new Promise(() => undefined);
29
+ })
30
+ .catch(rejected);
31
+ });
32
+ const sweepClosedTabs = async (base) => {
33
+ // Names first, locks second: a tab that creates its directory between the two
34
+ // reads already holds its lock, so it is never mistaken for a closed one.
35
+ const names = [];
36
+ for await (const name of base.keys())
37
+ names.push(name);
38
+ const { held = [] } = await navigator.locks.query();
39
+ const live = new Set(held.map((l) => l.name));
40
+ for (const name of names) {
41
+ if (!live.has(lockName(name)))
42
+ await base.removeEntry(name, { recursive: true }).catch(() => undefined);
43
+ }
44
+ };
45
+ const openTabDirs = async () => {
46
+ const base = await (await navigator.storage.getDirectory()).getDirectoryHandle(OPFS_ROOT, { create: true });
47
+ if (navigator.locks) {
48
+ await (tabLock !== null && tabLock !== void 0 ? tabLock : (tabLock = holdTabLock().catch((e) => {
49
+ tabLock = null;
50
+ throw e;
51
+ })));
52
+ }
53
+ return { base, dir: await base.getDirectoryHandle(getTabId(), { create: true }) };
54
+ };
55
+ /** Null when OPFS or `createWritable` (missing on Safari's main thread) isn't there. */
56
+ export const createOpfsOutput = async () => {
57
+ var _a;
58
+ try {
59
+ if (typeof navigator === 'undefined' || !((_a = navigator.storage) === null || _a === void 0 ? void 0 : _a.getDirectory))
60
+ return null;
61
+ const { base, dir } = await (tabDirsPromise !== null && tabDirsPromise !== void 0 ? tabDirsPromise : (tabDirsPromise = openTabDirs().catch((e) => {
62
+ tabDirsPromise = null;
63
+ throw e;
64
+ })));
65
+ // Without Web Locks another tab's live files can't be told apart: no sweep.
66
+ if (navigator.locks)
67
+ await sweepClosedTabs(base).catch(() => undefined);
68
+ const name = `${++fileCount}.mp4`;
69
+ const handle = await dir.getFileHandle(name, { create: true }).catch((e) => {
70
+ // The directory was removed under us (site data cleared): reopen it next time.
71
+ if ((e === null || e === void 0 ? void 0 : e.name) === 'NotFoundError')
72
+ tabDirsPromise = null;
73
+ throw e;
74
+ });
75
+ if (typeof handle.createWritable !== 'function') {
76
+ await dir.removeEntry(name).catch(() => undefined);
77
+ return null;
78
+ }
79
+ const writable = await handle.createWritable();
80
+ const discard = async () => {
81
+ // Rejects harmlessly once mediabunny holds the writer; it closes it then.
82
+ await writable.abort().catch(() => undefined);
83
+ await dir.removeEntry(name).catch(() => undefined);
84
+ };
85
+ // OPFS files carry no MIME type, and callers derive the extension from it.
86
+ const read = async () => (await handle.getFile()).slice(0, undefined, 'video/mp4');
87
+ return { writable, read, discard };
88
+ }
89
+ catch (e) {
90
+ console.warn('[compress] OPFS unavailable, buffering output in memory:', e);
91
+ return null;
92
+ }
93
+ };
94
+ //# sourceMappingURL=opfs-output.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opfs-output.js","sourceRoot":"","sources":["../../src/opfs-output.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebCodecs output goes to the origin private file system so a long video\n * never sits whole in memory. The File handed back stays readable only while\n * its OPFS entry exists, and callers keep it well past `compressVideo` (until\n * the upload is done, or longer for a retry), so a successful output stays\n * until the caller releases it with `finishUpload({ jobId })` or the tab\n * closes. Each tab writes under its own directory and holds a Web Lock named\n * after it for its whole life; every compress removes the directories whose\n * lock nobody holds any more, i.e. those of closed tabs. A failed or cancelled\n * job deletes its own file straight away.\n */\nconst OPFS_ROOT = 'native-video-compressor';\nconst lockName = (tabId: string) => `${OPFS_ROOT}:${tabId}`;\n\nlet tabId: string | null = null;\nconst getTabId = () => (tabId ??= crypto.randomUUID());\nlet fileCount = 0;\n\n// Memoised apart from the directories: they are reopened after site data is\n// cleared, but this tab still holds its lock, and requesting it again would\n// wait forever on ourselves.\nlet tabLock: Promise<void> | null = null;\nlet tabDirsPromise: Promise<{ base: FileSystemDirectoryHandle; dir: FileSystemDirectoryHandle }> | null = null;\n\n// A rejected request (e.g. a sandboxed iframe) rejects here, so openTabDirs\n// fails and the caller buffers in memory instead of waiting forever.\nconst holdTabLock = () =>\n new Promise<void>((granted, rejected) => {\n navigator.locks\n .request(lockName(getTabId()), () => {\n granted();\n return new Promise<never>(() => undefined);\n })\n .catch(rejected);\n });\n\nconst sweepClosedTabs = async (base: FileSystemDirectoryHandle) => {\n // Names first, locks second: a tab that creates its directory between the two\n // reads already holds its lock, so it is never mistaken for a closed one.\n const names: string[] = [];\n for await (const name of base.keys()) names.push(name);\n const { held = [] } = await navigator.locks.query();\n const live = new Set(held.map((l) => l.name));\n for (const name of names) {\n if (!live.has(lockName(name))) await base.removeEntry(name, { recursive: true }).catch(() => undefined);\n }\n};\n\nconst openTabDirs = async () => {\n const base = await (await navigator.storage.getDirectory()).getDirectoryHandle(OPFS_ROOT, { create: true });\n if (navigator.locks) {\n await (tabLock ??= holdTabLock().catch((e) => {\n tabLock = null;\n throw e;\n }));\n }\n return { base, dir: await base.getDirectoryHandle(getTabId(), { create: true }) };\n};\n\nexport interface OpfsOutput {\n writable: FileSystemWritableFileStream;\n read: () => Promise<Blob>;\n discard: () => Promise<void>;\n}\n\n/** Null when OPFS or `createWritable` (missing on Safari's main thread) isn't there. */\nexport const createOpfsOutput = async (): Promise<OpfsOutput | null> => {\n try {\n if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;\n const { base, dir } = await (tabDirsPromise ??= openTabDirs().catch((e) => {\n tabDirsPromise = null;\n throw e;\n }));\n // Without Web Locks another tab's live files can't be told apart: no sweep.\n if (navigator.locks) await sweepClosedTabs(base).catch(() => undefined);\n const name = `${++fileCount}.mp4`;\n const handle = await dir.getFileHandle(name, { create: true }).catch((e) => {\n // The directory was removed under us (site data cleared): reopen it next time.\n if (e?.name === 'NotFoundError') tabDirsPromise = null;\n throw e;\n });\n if (typeof handle.createWritable !== 'function') {\n await dir.removeEntry(name).catch(() => undefined);\n return null;\n }\n const writable = await handle.createWritable();\n const discard = async () => {\n // Rejects harmlessly once mediabunny holds the writer; it closes it then.\n await writable.abort().catch(() => undefined);\n await dir.removeEntry(name).catch(() => undefined);\n };\n // OPFS files carry no MIME type, and callers derive the extension from it.\n const read = async () => (await handle.getFile()).slice(0, undefined, 'video/mp4');\n return { writable, read, discard };\n } catch (e) {\n console.warn('[compress] OPFS unavailable, buffering output in memory:', e);\n return null;\n }\n};\n"]}
package/dist/esm/web.d.ts CHANGED
@@ -1,33 +1,51 @@
1
1
  import { WebPlugin } from '@capacitor/core';
2
2
  import type { NativeVideoCompressorPlugin, CompressOptions, CompressResult, CancelOptions, CancelResult } from './definitions';
3
+ export declare const webCodecsSize: (displayWidth: number, displayHeight: number, shortSide: number) => {
4
+ width: number;
5
+ } | {
6
+ height: number;
7
+ };
8
+ export declare const ffmpegScaleFilter: (shortSide: number) => string;
9
+ export declare const capBitrate: (target: number, sourceBitrate: number | undefined, floor: number, headroom?: number) => number;
10
+ export declare const codecHeadroom: (codec: string | null | undefined) => number;
3
11
  export declare class NativeVideoCompressorWeb extends WebPlugin implements NativeVideoCompressorPlugin {
4
12
  private ffmpeg;
5
13
  private isLoaded;
6
14
  /** The mediabunny conversion running now, so `cancel()` can abort it. */
7
15
  private activeConversion;
8
- /** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
9
- private currentJobId;
16
+ /** The job `compressVideo` is running right now; a fresh object per call, so two calls never look alike. */
17
+ private currentJob;
10
18
  private cancelRequested;
19
+ /** Kept OPFS outputs by jobId, deleted by `finishUpload`. */
20
+ private keptOutputs;
11
21
  initialize(): Promise<{
12
22
  success: boolean;
13
23
  message?: string;
14
24
  }>;
25
+ finishUpload({ jobId }: {
26
+ jobId: string;
27
+ }): Promise<{
28
+ released: boolean;
29
+ }>;
15
30
  compressVideo(options: CompressOptions): Promise<CompressResult>;
16
31
  /**
17
32
  * Kill whichever engine is mid-compress. Only one is ever active at a time,
18
33
  * so acting on both is safe.
19
34
  *
20
35
  * WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
21
- * with `ConversionCanceledError`, surfaced as a `CancelledError` below.
36
+ * with `ConversionCanceledError`, surfaced as a CANCELLED `CompressError` below.
22
37
  * ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
23
38
  * message poll while encoding, so `worker.terminate()` is the only way to
24
39
  * stop it — its cost is the loaded core, which is why the core URLs are
25
40
  * cached above.
26
41
  */
27
42
  cancel(options?: CancelOptions): Promise<CancelResult>;
43
+ private stopEngines;
28
44
  private fetchSourceBlob;
29
45
  private canUseWebCodecs;
30
46
  private compressWithWebCodecs;
47
+ /** False when mediabunny can't convert this input. */
48
+ private runConversion;
31
49
  private loadFFmpeg;
32
50
  private compressWithFfmpeg;
33
51
  }