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.
package/README.md CHANGED
@@ -164,7 +164,7 @@ Compressing a long video runs for minutes, so give the user a way out. Pass a
164
164
  `jobId` when starting, then cancel with the same id:
165
165
 
166
166
  ```typescript
167
- import { NativeVideoCompressor, CANCELLED } from 'capacitor-native-video-compressor';
167
+ import { NativeVideoCompressor, CANCELLED, STORAGE_FULL, TOO_LARGE_FOR_WEB } from 'capacitor-native-video-compressor';
168
168
 
169
169
  const jobId = crypto.randomUUID();
170
170
 
@@ -179,6 +179,13 @@ try {
179
179
  // The user asked for this — no error toast, no retry prompt.
180
180
  return;
181
181
  }
182
+ if (error.code === TOO_LARGE_FOR_WEB) {
183
+ // Web only: WebCodecs is unavailable or failed, and the file is over the 200 MB ffmpeg.wasm limit.
184
+ // Upload the original, or ask for a shorter clip.
185
+ }
186
+ if (error.code === STORAGE_FULL) {
187
+ // Web only: the browser storage quota is full. Retryable once space is freed.
188
+ }
182
189
  throw error;
183
190
  }
184
191
 
@@ -205,7 +212,7 @@ different job was — it never throws.
205
212
 
206
213
  ### Quality Presets
207
214
 
208
- 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):
215
+ The `quality` parameter determines the output resolution and compression ratio. Each tier caps the **short** side of the displayed video (the height of a landscape video, the width of a portrait one); the long side follows the aspect ratio. A source smaller than the tier is never upscaled. On Android both sides are rounded to multiples of 16, so 540 becomes 528.
209
216
 
210
217
  | Preset | Target Resolution | Best For |
211
218
  | ----------- | ----------------- | ----------------------------------------- |
@@ -216,7 +223,7 @@ The `quality` parameter determines the output resolution and compression ratio.
216
223
  | `360P` | ~ 360p | Very small files |
217
224
  | `VERY_LOW` | ~ 240p | Extreme compression, noticeably pixelated |
218
225
 
219
- > **Note on Web Platform**: On Web, lower qualities also aggressively reduce the Audio Bitrate (down to 48kbps for VERY_LOW) to maximize space savings. On Native, audio maintains steady quality.
226
+ > **Bitrate**: The video bitrate never goes above the source's own bitrate, so a lean source is not inflated; the Web ffmpeg fallback is the exception, as it uses CRF only (iOS and Web WebCodecs allow 1.5× the source for HEVC, VP9 and AV1, which need less than H.264 for the same picture). On Web, the ffmpeg fallback lowers the audio bitrate with the tier (down to 48 kbps for VERY_LOW). On iOS, AAC audio is capped at the source rate and snapped down to 64, 96 or 128 kbps.
220
227
 
221
228
  ### Platform Differences for `sourcePath`
222
229
 
@@ -229,9 +236,9 @@ The `quality` parameter determines the output resolution and compression ratio.
229
236
 
230
237
  * [`compressVideo(...)`](#compressvideo)
231
238
  * [`cancel(...)`](#cancel)
239
+ * [`finishUpload(...)`](#finishupload)
232
240
  * [`initialize()`](#initialize)
233
241
  * [`addListener('onProgress', ...)`](#addlisteneronprogress-)
234
- * [`addListener('onCancelRequested', ...)`](#addlisteneroncancelrequested-)
235
242
  * [Interfaces](#interfaces)
236
243
 
237
244
  </docgen-index>
@@ -245,6 +252,16 @@ The `quality` parameter determines the output resolution and compression ratio.
245
252
  compressVideo(options: CompressOptions) => Promise<CompressResult>
246
253
  ```
247
254
 
255
+ Compress one video and resolve with the path of the result.
256
+
257
+ Android: the work runs inside a WorkManager worker, which keeps the
258
+ process out of the frozen cached state while the user is away — there is
259
+ no foreground service and no notification, because Google Play classes
260
+ this use case as data sync plus local processing and rejects
261
+ `FOREGROUND_SERVICE_MEDIA_PROCESSING` for it. WorkManager stops a worker
262
+ after 10 minutes, so a compress that runs longer rejects with
263
+ {@link CANCELLED}.
264
+
248
265
  | Param | Type |
249
266
  | ------------- | ----------------------------------------------------------- |
250
267
  | **`options`** | <code><a href="#compressoptions">CompressOptions</a></code> |
@@ -276,6 +293,31 @@ thread (native), and none of them watch an AbortSignal.
276
293
  --------------------
277
294
 
278
295
 
296
+ ### finishUpload(...)
297
+
298
+ ```typescript
299
+ finishUpload(options: { jobId: string; }) => Promise<{ released: boolean; }>
300
+ ```
301
+
302
+ Release the worker that `holdForUpload` kept alive for this job.
303
+
304
+ Call it on every terminal branch of the upload, success or failure. A
305
+ missed call leaves the device feeding an idle job until the hold expires.
306
+ `released` is false when no job by that id was still being held, which is
307
+ the normal outcome of a late or duplicate call, not an error.
308
+
309
+ Web: deletes the compressed output instead, so call it only once the File
310
+ is no longer needed (not before a retry).
311
+
312
+ | Param | Type |
313
+ | ------------- | ------------------------------- |
314
+ | **`options`** | <code>{ jobId: string; }</code> |
315
+
316
+ **Returns:** <code>Promise&lt;{ released: boolean; }&gt;</code>
317
+
318
+ --------------------
319
+
320
+
279
321
  ### initialize()
280
322
 
281
323
  ```typescript
@@ -303,29 +345,6 @@ addListener(eventName: 'onProgress', listenerFunc: (info: { status: string; perc
303
345
  --------------------
304
346
 
305
347
 
306
- ### addListener('onCancelRequested', ...)
307
-
308
- ```typescript
309
- addListener(eventName: 'onCancelRequested', listenerFunc: () => void) => Promise<PluginListenerHandle>
310
- ```
311
-
312
- Android: user đã bấm "Huỷ" trên notification của foreground service.
313
-
314
- Pha nén được plugin tự huỷ trước khi event này bắn, nhưng việc caller định
315
- làm với kết quả nén - upload chẳng hạn - thì không ai dừng hộ được, nên
316
- caller phải tự abort. Bỏ qua event này nghĩa là nút Huỷ không thật sự huỷ,
317
- và Play đánh trượt tiêu chí "can be terminated or stopped by the user".
318
-
319
- | Param | Type |
320
- | ------------------ | -------------------------------- |
321
- | **`eventName`** | <code>'onCancelRequested'</code> |
322
- | **`listenerFunc`** | <code>() =&gt; void</code> |
323
-
324
- **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
325
-
326
- --------------------
327
-
328
-
329
348
  ### Interfaces
330
349
 
331
350
 
@@ -339,12 +358,13 @@ và Play đánh trượt tiêu chí "can be terminated or stopped by the user".
339
358
 
340
359
  #### CompressOptions
341
360
 
342
- | Prop | Type | Description |
343
- | ---------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
344
- | **`sourcePath`** | <code>string</code> | |
345
- | **`destPath`** | <code>string</code> | |
346
- | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
347
- | **`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. |
361
+ | Prop | Type | Description |
362
+ | ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
363
+ | **`sourcePath`** | <code>string</code> | |
364
+ | **`destPath`** | <code>string</code> | |
365
+ | **`quality`** | <code>'VERY_HIGH' \| 'HIGH' \| 'MEDIUM' \| 'LOW' \| 'VERY_LOW' \| '360P'</code> | |
366
+ | **`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. |
367
+ | **`holdForUpload`** | <code>boolean</code> | Android only: keep the background worker alive after the compress ends, until `finishUpload({ jobId })` is called. The upload runs as JavaScript in the webview, and the moment the worker returns the process becomes cached and is frozen — measured on Android 16, an in-flight upload dies about five seconds later. Set this whenever the compressed file is about to be uploaded, and always pair it with a `finishUpload` in a `finally`. |
348
368
 
349
369
 
350
370
  #### CancelResult
@@ -3,6 +3,7 @@ ext {
3
3
  androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
4
  androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
5
  androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+ androidxWorkVersion = project.hasProperty('androidxWorkVersion') ? rootProject.ext.androidxWorkVersion : '2.10.0'
6
7
  }
7
8
 
8
9
  buildscript {
@@ -21,10 +22,9 @@ android {
21
22
  namespace = "com.atomic.videocompressor"
22
23
  compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
23
24
  defaultConfig {
24
- // 35, không phải 24: foreground service của pha nén khai mediaProcessing,
25
- // type chỉ tồn tại từ API 35. Dưới mức đó hệ thống đọc ra "type unknown"
26
- // và startForeground() ném InvalidForegroundServiceTypeException.
27
- minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 35
25
+ // 24 trở lại: mức 35 trước đây chỉ để đáp ứng foreground service type
26
+ // mediaProcessing, thứ đã gỡ hẳn. WorkManager chạy từ API 21.
27
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
28
28
  targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
29
29
  versionCode 1
30
30
  versionName "1.0"
@@ -60,4 +60,6 @@ dependencies {
60
60
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
61
61
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
62
62
  implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
63
+ // Giữ process khỏi bị đóng băng trong lúc nén, thay cho foreground service.
64
+ implementation "androidx.work:work-runtime:$androidxWorkVersion"
63
65
  }
@@ -1,20 +1,11 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3
- <!-- Chỉ mediaProcessing: service sống đúng bằng pha nén, là use case duy
4
- nhất ở đây thực sự chạy khi user rời app. Không khai dataSync nữa, vì
5
- pha upload chạy bằng JS trong webview nên nó đứng lại ngay khi app
6
- xuống nền - giữ foreground service cho nó là khai một việc không diễn
7
- ra, và Play xét FGS theo use case chứ không theo số service.
2
+ <!-- Cố ý rỗng. Google Play xếp "nén rồi upload video do user chọn" vào data
3
+ sync + local processing chứ không phải media transcoding, nên
4
+ FOREGROUND_SERVICE_MEDIA_PROCESSING bị từ chối ở version code 32 kèm
5
+ yêu cầu chuyển sang WorkManager.
8
6
 
9
- mediaProcessing chỉ có từ API 35, và ở đây không còn type nào để hạ
10
- xuống, nên minSdk của plugin là 35 (xem android/build.gradle). -->
11
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" />
12
- <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
13
-
14
- <application>
15
- <service
16
- android:name="com.atomic.videocompressor.VideoCompressionService"
17
- android:foregroundServiceType="mediaProcessing"
18
- android:exported="false" />
19
- </application>
7
+ Pha nén giờ chạy trong VideoCompressionWorker, thứ WorkManager tự
8
+ quản lý - không service nào để khai, và mọi permission plugin từng
9
+ thêm vào app (FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROCESSING,
10
+ POST_NOTIFICATIONS) đều đã gỡ. Đừng thêm lại. -->
20
11
  </manifest>
@@ -1,11 +1,141 @@
1
1
  package com.atomic.videocompressor;
2
2
 
3
- import com.getcapacitor.Logger;
3
+ import java.util.regex.Pattern;
4
4
 
5
+ /**
6
+ * Output size, bitrate and leftover-file rules for one compress, kept free of
7
+ * android.* so they run as plain JVM unit tests. Same ladder as iOS
8
+ * (NativeVideoCompressorPlugin.swift) and the web QUALITY_LADDER.
9
+ */
5
10
  public class NativeVideoCompressor {
6
11
 
7
- public String echo(String value) {
8
- Logger.info("Echo", value);
9
- return value;
12
+ /**
13
+ * LightCompressor 1.3.2 getMediaPath copies any source it can't resolve
14
+ * through MediaStore `_data` (file:// and Photo Picker URIs) to
15
+ * `dataDir/<System.currentTimeMillis()>` and never deletes it.
16
+ */
17
+ private static final Pattern SOURCE_COPY_NAME = Pattern.compile("\\d{13}");
18
+
19
+ static boolean isSourceCopyName(String name) {
20
+ return name != null && SOURCE_COPY_NAME.matcher(name).matches();
21
+ }
22
+
23
+ /**
24
+ * A copy older than `cutoff` (process start, or "now" on a WebView reload
25
+ * when every job is orphaned) can't belong to a live job.
26
+ */
27
+ static boolean isStaleSourceCopy(String name, long lastModified, long cutoff) {
28
+ return isSourceCopyName(name) && lastModified < cutoff;
29
+ }
30
+
31
+ /**
32
+ * Factors LightCompressor 1.3.2 applies to the source bitrate when no
33
+ * explicit bitrate is given (CompressorUtils.getBitrate), in VideoQuality
34
+ * order VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH.
35
+ */
36
+ static final double[] SOURCE_FACTORS = { 0.1, 0.2, 0.3, 0.4, 0.6 };
37
+
38
+ static final class Target {
39
+
40
+ final int shortSide;
41
+ final long bitrate;
42
+
43
+ Target(int shortSide, long bitrate) {
44
+ this.shortSide = shortSide;
45
+ this.bitrate = bitrate;
46
+ }
47
+ }
48
+
49
+ static Target target(String quality) {
50
+ if (quality == null) quality = "MEDIUM";
51
+ switch (quality) {
52
+ case "VERY_HIGH":
53
+ return new Target(1080, 4_500_000);
54
+ case "HIGH":
55
+ return new Target(720, 2_500_000);
56
+ case "LOW":
57
+ return new Target(480, 1_000_000);
58
+ case "360P":
59
+ return new Target(360, 700_000);
60
+ case "VERY_LOW":
61
+ return new Target(240, 400_000);
62
+ default:
63
+ return new Target(540, 1_500_000);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * {width, height} in the track's coded orientation, or null for an empty
69
+ * track. `shortSide` caps the shorter side and the long side keeps the
70
+ * aspect ratio. A 90/270 rotation only swaps the sides, and LightCompressor
71
+ * swaps explicit dimensions itself for those, so coded order is right.
72
+ * Unlike iOS, both sides are multiples of 16 (so 540 becomes 528). The long
73
+ * side follows the aligned short side and rounds to the nearest 16, so the
74
+ * aspect ratio drifts less than flooring each side on its own would.
75
+ */
76
+ static int[] outputSize(int width, int height, int shortSide) {
77
+ int sourceShort = Math.min(width, height);
78
+ if (sourceShort <= 0) return null;
79
+ int sourceLong = Math.max(width, height);
80
+ int outShort = align16(Math.min(shortSide, sourceShort));
81
+ long nearestLong = Math.round(((double) sourceLong * outShort) / sourceShort / 16) * 16;
82
+ int outLong = (int) Math.max(16, Math.min(nearestLong, align16(sourceLong)));
83
+ return width >= height ? new int[] { outLong, outShort } : new int[] { outShort, outLong };
84
+ }
85
+
86
+ // Some MediaTek and older Qualcomm AVC encoders fail configure or draw green
87
+ // edges off 16-pixel macroblocks; LightCompressor's own sizing aligns for that.
88
+ // Floor so it never upscales.
89
+ private static int align16(double value) {
90
+ return Math.max(16, (int) (value / 16) * 16);
91
+ }
92
+
93
+ static final class BitratePlan {
94
+
95
+ /** Whole Mbps for Configuration.videoBitrateInMbps, or null to scale the source instead. */
96
+ final Integer mbps;
97
+
98
+ /** Index into SOURCE_FACTORS when mbps is null. */
99
+ final int sourceFactorIndex;
100
+
101
+ BitratePlan(Integer mbps, int sourceFactorIndex) {
102
+ this.mbps = mbps;
103
+ this.sourceFactorIndex = sourceFactorIndex;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * LightCompressor 1.3.2 only takes whole Mbps or a fixed fraction of the
109
+ * source bitrate, so pick whichever lands closest to the target, never
110
+ * above the source. Null when the source bitrate is unknown.
111
+ */
112
+ static BitratePlan bitrate(long target, long source) {
113
+ if (source <= 0) return null;
114
+ BitratePlan best = null;
115
+ long bestDistance = Long.MAX_VALUE;
116
+ long bestBps = 0;
117
+
118
+ // Down to 1, not just floor/ceil of the target: a source below the
119
+ // target should get the largest whole Mbps it still fits.
120
+ for (long mbps = target / 1_000_000 + 1; mbps >= 1; mbps--) {
121
+ long bps = mbps * 1_000_000;
122
+ if (bps > source) continue;
123
+ long distance = Math.abs(bps - target);
124
+ if (distance < bestDistance || (distance == bestDistance && bps < bestBps)) {
125
+ best = new BitratePlan((int) mbps, -1);
126
+ bestDistance = distance;
127
+ bestBps = bps;
128
+ }
129
+ }
130
+ for (int i = 0; i < SOURCE_FACTORS.length; i++) {
131
+ long bps = Math.round(source * SOURCE_FACTORS[i]);
132
+ long distance = Math.abs(bps - target);
133
+ if (distance < bestDistance || (distance == bestDistance && bps < bestBps)) {
134
+ best = new BitratePlan(null, i);
135
+ bestDistance = distance;
136
+ bestBps = bps;
137
+ }
138
+ }
139
+ return best;
10
140
  }
11
141
  }