capacitor-native-video-compressor 2.0.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
 
@@ -299,6 +306,9 @@ missed call leaves the device feeding an idle job until the hold expires.
299
306
  `released` is false when no job by that id was still being held, which is
300
307
  the normal outcome of a late or duplicate call, not an error.
301
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
+
302
312
  | Param | Type |
303
313
  | ------------- | ------------------------------- |
304
314
  | **`options`** | <code>{ jobId: string; }</code> |
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  package com.atomic.videocompressor;
2
2
 
3
+ import android.media.MediaMetadataRetriever;
3
4
  import android.net.Uri;
4
5
  import android.util.Log;
5
6
  import androidx.annotation.NonNull;
@@ -21,6 +22,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
21
22
  import java.io.File;
22
23
  import java.util.Arrays;
23
24
  import java.util.Collections;
25
+ import java.util.HashSet;
26
+ import java.util.Set;
24
27
  import java.util.concurrent.CountDownLatch;
25
28
  import java.util.concurrent.TimeUnit;
26
29
  import java.util.concurrent.atomic.AtomicBoolean;
@@ -38,12 +41,30 @@ public class NativeVideoCompressorPlugin extends Plugin {
38
41
 
39
42
  private static final String STORAGE_SUBFOLDER = "compressed_videos";
40
43
 
44
+ /** Gắn lên mọi work của plugin để dọn được job mồ côi của phiên WebView trước. */
45
+ private static final String WORK_TAG = "native-video-compressor";
46
+
47
+ /** Thứ tự khớp NativeVideoCompressor.SOURCE_FACTORS. */
48
+ private static final VideoQuality[] QUALITY_BY_SOURCE_FACTOR = {
49
+ VideoQuality.VERY_LOW,
50
+ VideoQuality.LOW,
51
+ VideoQuality.MEDIUM,
52
+ VideoQuality.HIGH,
53
+ VideoQuality.VERY_HIGH
54
+ };
55
+
41
56
  /**
42
57
  * Trần cho pha giữ-chờ-upload. Ngắn hơn mốc 10 phút mà WorkManager tự cắt,
43
58
  * để một caller chết giữa chừng không chiếm quota job tới tận phút cuối.
44
59
  */
45
60
  private static final long HOLD_FOR_UPLOAD_TIMEOUT_MINUTES = 8;
46
61
 
62
+ /**
63
+ * Mốc process này nạp plugin - trước mọi lần nén của nó. Bản sao nguồn cũ
64
+ * hơn mốc này là của một process đã chết.
65
+ */
66
+ private static final long PROCESS_START_MS = System.currentTimeMillis();
67
+
47
68
  /** The job compressVideo is running right now, for cancel({ jobId }). */
48
69
  private String currentJobId = null;
49
70
 
@@ -60,9 +81,93 @@ public class NativeVideoCompressorPlugin extends Plugin {
60
81
  call.resolve(ret);
61
82
  }
62
83
 
84
+ /** Bridge mới (Activity dựng lại): mọi job còn chạy thuộc phiên cũ. */
85
+ @Override
86
+ public void load() {
87
+ cancelOrphans(PROCESS_START_MS);
88
+ }
89
+
90
+ /**
91
+ * Bridge gọi hàm này ở onPageStarted (Bridge.reset), tức mỗi lần WebView
92
+ * tải lại trang - load() thì không chạy lại. JS cũ đã chết nên không còn
93
+ * ai nhận kết quả các job đang chạy.
94
+ *
95
+ * Mốc dọn là "bây giờ": JS mới chưa chạy nên mọi bản sao nguồn đều mồ
96
+ * côi, kể cả bản một job huỷ giữa pha chép vẫn đang ghi tiếp sau claim().
97
+ */
98
+ @Override
99
+ public void removeAllListeners() {
100
+ super.removeAllListeners();
101
+ cancelOrphans(System.currentTimeMillis());
102
+ }
103
+
104
+ /**
105
+ * Dừng job trong process trước, đồng bộ, rồi mới huỷ work. Huỷ work là bất
106
+ * đồng bộ: onStopped tới muộn sẽ gọi VideoCompressor.cancel() - vốn global
107
+ * - và giết luôn job đầu tiên của phiên mới. Dừng trước thì onStopped gặp
108
+ * job đã stopped và không làm gì.
109
+ *
110
+ * ponytail: cờ isRunning của LightCompressor là global; nếu phiên mới gọi
111
+ * start trước khi vòng nén cũ kịp đọc cờ (mỗi ~100 µs) thì job cũ chạy
112
+ * tiếp. JS boot mất cả giây nên gần như không xảy ra; cần chặt hơn thì
113
+ * phải bỏ LightCompressor.
114
+ */
115
+ private void cancelOrphans(long copiesOlderThan) {
116
+ VideoCompressionWorker.stopAll();
117
+ try {
118
+ WorkManager.getInstance(getContext()).cancelAllWorkByTag(WORK_TAG);
119
+ } catch (Exception e) {
120
+ Log.w(TAG, "Could not cancel orphaned work", e);
121
+ }
122
+ // Process bị kill giữa chừng thì không job nào kịp dọn bản sao của nó.
123
+ for (File copy : sourceCopies()) {
124
+ if (NativeVideoCompressor.isStaleSourceCopy(copy.getName(), copy.lastModified(), copiesOlderThan)) {
125
+ deleteQuietly(copy);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** File thường ở gốc dataDir mang tên đúng 13 chữ số - xem NativeVideoCompressor.isSourceCopyName. */
131
+ private File[] sourceCopies() {
132
+ try {
133
+ File[] files = new File(getContext().getApplicationInfo().dataDir).listFiles(
134
+ (File f) -> f.isFile() && NativeVideoCompressor.isSourceCopyName(f.getName())
135
+ );
136
+ if (files != null) return files;
137
+ } catch (Exception e) {
138
+ Log.w(TAG, "Could not list source copies", e);
139
+ }
140
+ return new File[0];
141
+ }
142
+
143
+ private Set<String> sourceCopyNames() {
144
+ Set<String> names = new HashSet<>();
145
+ for (File copy : sourceCopies()) names.add(copy.getName());
146
+ return names;
147
+ }
148
+
63
149
  /**
64
- * Stop the running compress. LightCompressor cancels its coroutine job and
65
- * fires onCancelled, which is already wired below.
150
+ * Xoá bản sao xuất hiện sau `before`. Nếu job khác đang chạy song song mà
151
+ * bản sao của nó bị xoá theo cũng vô hại: LightCompressor 1.3.2 chỉ
152
+ * null-check đường dẫn đó, còn nén thì đọc thẳng từ Uri gốc.
153
+ */
154
+ private void deleteSourceCopiesSince(Set<String> before) {
155
+ for (File copy : sourceCopies()) {
156
+ if (!before.contains(copy.getName())) deleteQuietly(copy);
157
+ }
158
+ }
159
+
160
+ private void deleteQuietly(File file) {
161
+ try {
162
+ if (!file.delete()) Log.w(TAG, "Could not delete " + file);
163
+ } catch (Exception e) {
164
+ Log.w(TAG, "Could not delete " + file, e);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Stop the running compress. The job is stopped directly and answers
170
+ * CANCELLED itself; LightCompressor's own callbacks after that are no-ops.
66
171
  */
67
172
  @PluginMethod
68
173
  public void cancel(PluginCall call) {
@@ -79,11 +184,12 @@ public class NativeVideoCompressorPlugin extends Plugin {
79
184
  synchronized (this) {
80
185
  cancelled = currentJobId != null && (requested == null || requested.equals(currentJobId));
81
186
  if (cancelled) {
82
- // Huỷ cả work: pha nén có thể chưa kịp bắt đầu, lúc đó
83
- // VideoCompressor.cancel() không có gì để huỷ và work vẫn sẽ
84
- // chạy sau đó. Huỷ work làm onStopped bắn và job tự kết thúc.
85
- cancelWork(currentJobId);
86
- VideoCompressor.cancel();
187
+ String jobId = currentJobId;
188
+ // Dừng job trực tiếp chứ không trông vào onStopped: work còn
189
+ // ENQUEUED thì huỷ xong WorkManager không hề dựng Worker, nên
190
+ // onStopped không bao giờ bắn và PluginCall sẽ treo.
191
+ if (!VideoCompressionWorker.stop(jobId)) VideoCompressor.cancel();
192
+ cancelWork(jobId);
87
193
  }
88
194
  }
89
195
 
@@ -131,34 +237,60 @@ public class NativeVideoCompressorPlugin extends Plugin {
131
237
 
132
238
  // Đọc quality từ Javascript (Mặc định là MEDIUM)
133
239
  String qualityString = call.getString("quality", "MEDIUM");
240
+ NativeVideoCompressor.Target target = NativeVideoCompressor.target(qualityString);
134
241
 
135
- VideoQuality videoQuality = VideoQuality.MEDIUM;
136
- if (qualityString != null) {
137
- if (qualityString.equals("360P")) {
138
- videoQuality = VideoQuality.LOW; // Map 360p to LOW quality on Android
139
- } else if (qualityString.equals("LOW")) {
140
- videoQuality = VideoQuality.VERY_LOW; // Adjusting others dynamically if needed
141
- } else {
142
- try {
143
- videoQuality = VideoQuality.valueOf(qualityString);
144
- } catch (IllegalArgumentException e) {
145
- videoQuality = VideoQuality.MEDIUM;
146
- }
147
- }
242
+ // Kích thước + bitrate theo cùng bậc thang với iOS/web. Đọc metadata
243
+ // hỏng thì để null, LightCompressor tự chọn như trước.
244
+ int[] size = null;
245
+ NativeVideoCompressor.BitratePlan bitrate = null;
246
+ MediaMetadataRetriever retriever = new MediaMetadataRetriever();
247
+ try {
248
+ retriever.setDataSource(getContext(), srcUri);
249
+ int width = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
250
+ int height = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
251
+ size = NativeVideoCompressor.outputSize(width, height, target.shortSide);
252
+ bitrate = NativeVideoCompressor.bitrate(
253
+ target.bitrate,
254
+ Long.parseLong(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE))
255
+ );
256
+ } catch (Exception e) {
257
+ // Containers without a bitrate tag (MKV, WebM, screen recordings) still keep the tier's size.
258
+ Log.w(TAG, "Could not read video metadata, using LightCompressor defaults for the rest", e);
259
+ } finally {
260
+ // release() khai báo IOException từ API 29; không dùng
261
+ // try-with-resources vì minSdk 24 chưa có AutoCloseable.
262
+ try {
263
+ retriever.release();
264
+ } catch (Exception ignored) {}
148
265
  }
149
266
 
150
- Log.d(TAG, "====== VIDEO COMPRESSION QUALITY RECEIVED: " + qualityString + "+ video quality: " + videoQuality + " ======");
267
+ // Với bitrate tính theo nguồn, quality chỉ còn chọn hệ số nhân của
268
+ // LightCompressor; với Mbps tường minh hoặc metadata hỏng thì giữ MEDIUM.
269
+ VideoQuality videoQuality =
270
+ bitrate != null && bitrate.mbps == null ? QUALITY_BY_SOURCE_FACTOR[bitrate.sourceFactorIndex] : VideoQuality.MEDIUM;
271
+
272
+ Log.d(
273
+ TAG,
274
+ "Compress " +
275
+ qualityString +
276
+ ": size=" +
277
+ (size == null ? "default" : size[0] + "x" + size[1]) +
278
+ ", mbps=" +
279
+ (bitrate == null ? "default" : bitrate.mbps) +
280
+ ", quality=" +
281
+ videoQuality
282
+ );
151
283
 
152
284
  // 2. Cấu hình thông số nén (Dành riêng cho LightCompressor 1.3.2)
153
- // Lưu ý: Java phải truyền đủ 9 tham số do thư viện gốc viết bằng Kotlin
285
+ // Thứ tự tham số theo Configuration.kt: height ĐỨNG TRƯỚC width.
154
286
  Configuration configuration = new Configuration(
155
287
  videoQuality, // Chất lượng nén
156
288
  false, // tắt tính năng giới hạn Bitrate tối thiểu của file gốc
157
- null, // videoBitrateInMbps (Ép buộc giá trị Bitrate để đảm bảo file luôn nhẹ đi)
289
+ bitrate == null ? null : bitrate.mbps, // videoBitrateInMbps
158
290
  false, // disableAudio
159
291
  false, // keepOriginalResolution
160
- null, // videoWidth
161
- null, // videoHeight
292
+ size == null ? null : Double.valueOf(size[1]), // videoHeight
293
+ size == null ? null : Double.valueOf(size[0]), // videoWidth
162
294
  Arrays.asList(fileName) // videoNames
163
295
  );
164
296
 
@@ -179,6 +311,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
179
311
 
180
312
  OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(VideoCompressionWorker.class)
181
313
  .setInputData(new Data.Builder().putString(VideoCompressionWorker.KEY_JOB_ID, jobId).build())
314
+ .addTag(WORK_TAG)
182
315
  .build();
183
316
 
184
317
  try {
@@ -220,6 +353,9 @@ public class NativeVideoCompressorPlugin extends Plugin {
220
353
  /** True khi nén xong có kết quả dùng được, tức là có cái để upload. */
221
354
  private volatile boolean compressed = false;
222
355
 
356
+ /** Bản sao nguồn có sẵn trước job này; claim() xoá những cái mới hơn. */
357
+ private volatile Set<String> copiesBefore = null;
358
+
223
359
  private boolean started = false;
224
360
  private boolean stopped = false;
225
361
 
@@ -247,6 +383,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
247
383
  // stop() đã chạy trước khi worker kịp khởi động: đừng bắt đầu
248
384
  // một lần nén mà không còn ai chờ kết quả.
249
385
  if (!stopped) {
386
+ copiesBefore = sourceCopyNames();
250
387
  VideoCompressor.start(
251
388
  getContext(),
252
389
  Collections.singletonList(srcUri), // Nhận vào một List chứa đường dẫn
@@ -305,19 +442,21 @@ public class NativeVideoCompressorPlugin extends Plugin {
305
442
  // chờ finishUpload nữa.
306
443
  released.countDown();
307
444
 
308
- if (wasRunning) {
309
- // onCancelled của LightCompressor sẽ mở chốt; không tự gọi
310
- // deliverCancelled ở đây để nhánh dọn file tạm vẫn chạy.
445
+ // Đã trả kết quả (đang giữ cho upload) thì không còn gì của job này
446
+ // để huỷ - cancel() global lúc đó chỉ giết job khác đang nén.
447
+ if (wasRunning && !delivered.get()) {
311
448
  try {
312
449
  VideoCompressor.cancel();
313
450
  } catch (Exception e) {
314
451
  Log.w(TAG, "Could not cancel compression", e);
315
- deliverCancelled();
316
452
  }
317
- } else {
318
- // Chưa bắt đầu thì không có callback nào bắn, phải tự kết thúc.
319
- deliverCancelled();
320
453
  }
454
+ // Luôn tự kết thúc: coroutine của LightCompressor bị huỷ thường ném
455
+ // CancellationException mà không bắn callback nào (trước onStart,
456
+ // hay lúc processAudio/finishMovie không đọc isRunning). claim()
457
+ // biến onCancelled tới sau thành no-op, còn onSuccess lỡ tới thì
458
+ // tự xoá output.
459
+ deliverCancelled();
321
460
  }
322
461
 
323
462
  private CompressionListener listener() {
@@ -334,6 +473,7 @@ public class NativeVideoCompressorPlugin extends Plugin {
334
473
  @Override
335
474
  public void onFailure(int i, @NonNull String failureMessage) {
336
475
  if (!claim()) return;
476
+ deleteOutput(fileName);
337
477
  call.reject("Nén thất bại: " + failureMessage);
338
478
  }
339
479
 
@@ -343,7 +483,12 @@ public class NativeVideoCompressorPlugin extends Plugin {
343
483
  // đọc `compressed` ngay khi chốt đó mở để quyết định có ở
344
484
  // lại chờ upload hay không.
345
485
  compressed = s != null;
346
- if (!claim()) return;
486
+ if (!claim()) {
487
+ // Đã trả CANCELLED trước đó (stop() tự kết thúc):
488
+ // không ai nhận file này.
489
+ deleteOutput(fileName);
490
+ return;
491
+ }
347
492
  // Nén xong, trả kết quả đường dẫn file mới về cho JS
348
493
  JSObject ret = new JSObject();
349
494
  ret.put("success", true);
@@ -379,6 +524,8 @@ public class NativeVideoCompressorPlugin extends Plugin {
379
524
  /** True đúng một lần, cho nhánh kết thúc đầu tiên tới đích. */
380
525
  private boolean claim() {
381
526
  if (!delivered.compareAndSet(false, true)) return false;
527
+ Set<String> before = copiesBefore;
528
+ if (before != null) deleteSourceCopiesSince(before);
382
529
  synchronized (NativeVideoCompressorPlugin.this) {
383
530
  if (jobId.equals(currentJobId)) currentJobId = null;
384
531
  }
@@ -400,10 +547,12 @@ public class NativeVideoCompressorPlugin extends Plugin {
400
547
  }
401
548
  }
402
549
 
403
- /** Best-effort: a partial file left in the cache is not worth failing over. */
550
+ /** Best-effort: a partial file left behind is not worth failing over. */
404
551
  private void deleteOutput(String fileName) {
405
552
  try {
406
- File dir = new File(getContext().getExternalFilesDir(null), STORAGE_SUBFOLDER);
553
+ // Nơi LightCompressor 1.3.2 ghi với AppSpecificStorageConfiguration:
554
+ // File(context.filesDir, "<subFolder>/<name>").
555
+ File dir = new File(getContext().getFilesDir(), STORAGE_SUBFOLDER);
407
556
  File partial = new File(dir, fileName);
408
557
  if (partial.exists()) {
409
558
  partial.delete();
@@ -81,6 +81,19 @@ public class VideoCompressionWorker extends Worker {
81
81
  return true;
82
82
  }
83
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
+
84
97
  @Nullable
85
98
  private volatile Job running = null;
86
99
 
@@ -106,7 +119,8 @@ public class VideoCompressionWorker extends Worker {
106
119
  job.run();
107
120
  } finally {
108
121
  running = null;
109
- pending.remove(jobId);
122
+ // Hai tham số: một phiên mới có thể đã đăng ký lại cùng jobId.
123
+ pending.remove(jobId, job);
110
124
  }
111
125
  return Result.success();
112
126
  }
package/dist/docs.json CHANGED
@@ -55,7 +55,7 @@
55
55
  ],
56
56
  "returns": "Promise<{ released: boolean; }>",
57
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.",
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
59
  "complexTypes": [],
60
60
  "slug": "finishupload"
61
61
  },
@@ -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;
@@ -74,6 +88,9 @@ export interface NativeVideoCompressorPlugin {
74
88
  * missed call leaves the device feeding an idle job until the hold expires.
75
89
  * `released` is false when no job by that id was still being held, which is
76
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).
77
94
  */
78
95
  finishUpload(options: {
79
96
  jobId: string;
@@ -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 * 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 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"]}
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>;