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 +13 -3
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressor.java +134 -4
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +184 -35
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionWorker.java +15 -1
- package/dist/docs.json +1 -1
- package/dist/esm/definitions.d.ts +17 -0
- package/dist/esm/definitions.js +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/opfs-output.d.ts +7 -0
- package/dist/esm/opfs-output.js +94 -0
- package/dist/esm/opfs-output.js.map +1 -0
- package/dist/esm/web.d.ts +19 -4
- package/dist/esm/web.js +193 -134
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +305 -133
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +306 -134
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressor.swift +34 -0
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressorPlugin.swift +118 -79
- package/ios/Tests/NativeVideoCompressorPluginTests/NativeVideoCompressorTests.swift +44 -0
- package/package.json +9 -4
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.
|
|
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
|
-
> **
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
289
|
+
bitrate == null ? null : bitrate.mbps, // videoBitrateInMbps
|
|
158
290
|
false, // disableAudio
|
|
159
291
|
false, // keepOriginalResolution
|
|
160
|
-
null, //
|
|
161
|
-
null, //
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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())
|
|
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
|
|
550
|
+
/** Best-effort: a partial file left behind is not worth failing over. */
|
|
404
551
|
private void deleteOutput(String fileName) {
|
|
405
552
|
try {
|
|
406
|
-
|
|
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
|
-
|
|
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;
|
package/dist/esm/definitions.js
CHANGED
|
@@ -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>;
|