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 +53 -33
- package/android/build.gradle +6 -4
- package/android/src/main/AndroidManifest.xml +8 -17
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressor.java +134 -4
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +385 -122
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionWorker.java +134 -0
- package/dist/docs.json +24 -24
- package/dist/esm/definitions.d.ts +52 -9
- 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 +21 -3
- package/dist/esm/web.js +194 -129
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +306 -128
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +307 -129
- 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/android/src/main/java/com/atomic/videocompressor/VideoCompressionService.java +0 -310
package/dist/plugin.js
CHANGED
|
@@ -7,40 +7,186 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
7
7
|
* retry prompt — and tell it apart from a genuine compression failure.
|
|
8
8
|
*/
|
|
9
9
|
const CANCELLED = 'CANCELLED';
|
|
10
|
+
/**
|
|
11
|
+
* `code` on the error `compressVideo` rejects with on web when WebCodecs can't
|
|
12
|
+
* encode the video and it is larger than 200 MB, the cap for the ffmpeg.wasm
|
|
13
|
+
* fallback. ffmpeg.wasm copies the whole input into wasm memory (~2 GB
|
|
14
|
+
* ceiling), so a large file there crashes or hangs the tab rather than failing
|
|
15
|
+
* cleanly.
|
|
16
|
+
*/
|
|
17
|
+
const TOO_LARGE_FOR_WEB = 'TOO_LARGE_FOR_WEB';
|
|
18
|
+
/**
|
|
19
|
+
* `code` on the error `compressVideo` rejects with on web when the browser
|
|
20
|
+
* refuses to store more of the output (storage quota). Retryable once space
|
|
21
|
+
* is freed.
|
|
22
|
+
*/
|
|
23
|
+
const STORAGE_FULL = 'STORAGE_FULL';
|
|
10
24
|
|
|
11
25
|
const NativeVideoCompressor = core.registerPlugin('NativeVideoCompressor', {
|
|
12
26
|
web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.NativeVideoCompressorWeb()),
|
|
13
27
|
});
|
|
14
28
|
|
|
15
29
|
/**
|
|
16
|
-
*
|
|
30
|
+
* WebCodecs output goes to the origin private file system so a long video
|
|
31
|
+
* never sits whole in memory. The File handed back stays readable only while
|
|
32
|
+
* its OPFS entry exists, and callers keep it well past `compressVideo` (until
|
|
33
|
+
* the upload is done, or longer for a retry), so a successful output stays
|
|
34
|
+
* until the caller releases it with `finishUpload({ jobId })` or the tab
|
|
35
|
+
* closes. Each tab writes under its own directory and holds a Web Lock named
|
|
36
|
+
* after it for its whole life; every compress removes the directories whose
|
|
37
|
+
* lock nobody holds any more, i.e. those of closed tabs. A failed or cancelled
|
|
38
|
+
* job deletes its own file straight away.
|
|
39
|
+
*/
|
|
40
|
+
const OPFS_ROOT = 'native-video-compressor';
|
|
41
|
+
const lockName = (tabId) => `${OPFS_ROOT}:${tabId}`;
|
|
42
|
+
let tabId = null;
|
|
43
|
+
const getTabId = () => (tabId !== null && tabId !== void 0 ? tabId : (tabId = crypto.randomUUID()));
|
|
44
|
+
let fileCount = 0;
|
|
45
|
+
// Memoised apart from the directories: they are reopened after site data is
|
|
46
|
+
// cleared, but this tab still holds its lock, and requesting it again would
|
|
47
|
+
// wait forever on ourselves.
|
|
48
|
+
let tabLock = null;
|
|
49
|
+
let tabDirsPromise = null;
|
|
50
|
+
// A rejected request (e.g. a sandboxed iframe) rejects here, so openTabDirs
|
|
51
|
+
// fails and the caller buffers in memory instead of waiting forever.
|
|
52
|
+
const holdTabLock = () => new Promise((granted, rejected) => {
|
|
53
|
+
navigator.locks
|
|
54
|
+
.request(lockName(getTabId()), () => {
|
|
55
|
+
granted();
|
|
56
|
+
return new Promise(() => undefined);
|
|
57
|
+
})
|
|
58
|
+
.catch(rejected);
|
|
59
|
+
});
|
|
60
|
+
const sweepClosedTabs = async (base) => {
|
|
61
|
+
// Names first, locks second: a tab that creates its directory between the two
|
|
62
|
+
// reads already holds its lock, so it is never mistaken for a closed one.
|
|
63
|
+
const names = [];
|
|
64
|
+
for await (const name of base.keys())
|
|
65
|
+
names.push(name);
|
|
66
|
+
const { held = [] } = await navigator.locks.query();
|
|
67
|
+
const live = new Set(held.map((l) => l.name));
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
if (!live.has(lockName(name)))
|
|
70
|
+
await base.removeEntry(name, { recursive: true }).catch(() => undefined);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const openTabDirs = async () => {
|
|
74
|
+
const base = await (await navigator.storage.getDirectory()).getDirectoryHandle(OPFS_ROOT, { create: true });
|
|
75
|
+
if (navigator.locks) {
|
|
76
|
+
await (tabLock !== null && tabLock !== void 0 ? tabLock : (tabLock = holdTabLock().catch((e) => {
|
|
77
|
+
tabLock = null;
|
|
78
|
+
throw e;
|
|
79
|
+
})));
|
|
80
|
+
}
|
|
81
|
+
return { base, dir: await base.getDirectoryHandle(getTabId(), { create: true }) };
|
|
82
|
+
};
|
|
83
|
+
/** Null when OPFS or `createWritable` (missing on Safari's main thread) isn't there. */
|
|
84
|
+
const createOpfsOutput = async () => {
|
|
85
|
+
var _a;
|
|
86
|
+
try {
|
|
87
|
+
if (typeof navigator === 'undefined' || !((_a = navigator.storage) === null || _a === void 0 ? void 0 : _a.getDirectory))
|
|
88
|
+
return null;
|
|
89
|
+
const { base, dir } = await (tabDirsPromise !== null && tabDirsPromise !== void 0 ? tabDirsPromise : (tabDirsPromise = openTabDirs().catch((e) => {
|
|
90
|
+
tabDirsPromise = null;
|
|
91
|
+
throw e;
|
|
92
|
+
})));
|
|
93
|
+
// Without Web Locks another tab's live files can't be told apart: no sweep.
|
|
94
|
+
if (navigator.locks)
|
|
95
|
+
await sweepClosedTabs(base).catch(() => undefined);
|
|
96
|
+
const name = `${++fileCount}.mp4`;
|
|
97
|
+
const handle = await dir.getFileHandle(name, { create: true }).catch((e) => {
|
|
98
|
+
// The directory was removed under us (site data cleared): reopen it next time.
|
|
99
|
+
if ((e === null || e === void 0 ? void 0 : e.name) === 'NotFoundError')
|
|
100
|
+
tabDirsPromise = null;
|
|
101
|
+
throw e;
|
|
102
|
+
});
|
|
103
|
+
if (typeof handle.createWritable !== 'function') {
|
|
104
|
+
await dir.removeEntry(name).catch(() => undefined);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const writable = await handle.createWritable();
|
|
108
|
+
const discard = async () => {
|
|
109
|
+
// Rejects harmlessly once mediabunny holds the writer; it closes it then.
|
|
110
|
+
await writable.abort().catch(() => undefined);
|
|
111
|
+
await dir.removeEntry(name).catch(() => undefined);
|
|
112
|
+
};
|
|
113
|
+
// OPFS files carry no MIME type, and callers derive the extension from it.
|
|
114
|
+
const read = async () => (await handle.getFile()).slice(0, undefined, 'video/mp4');
|
|
115
|
+
return { writable, read, discard };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
console.warn('[compress] OPFS unavailable, buffering output in memory:', e);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rejection shape for every coded failure. `code` matches what `call.reject`
|
|
17
125
|
* puts on the error object on iOS/Android, so one `error.code === 'CANCELLED'`
|
|
18
|
-
* check works on every platform
|
|
126
|
+
* check works on every platform and on both web engines below.
|
|
19
127
|
*/
|
|
20
|
-
class
|
|
21
|
-
constructor() {
|
|
22
|
-
super(
|
|
23
|
-
this.code =
|
|
24
|
-
this.name = '
|
|
128
|
+
class CompressError extends Error {
|
|
129
|
+
constructor(code, message) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.code = code;
|
|
132
|
+
this.name = 'CompressError';
|
|
25
133
|
}
|
|
26
134
|
}
|
|
135
|
+
const cancelledError = () => new CompressError(CANCELLED, 'Đã hủy nén');
|
|
136
|
+
const isQuotaError = (e) => {
|
|
137
|
+
const { name, cause } = (e !== null && e !== void 0 ? e : {});
|
|
138
|
+
return name === 'QuotaExceededError' || (!!cause && isQuotaError(cause));
|
|
139
|
+
};
|
|
140
|
+
/** ffmpeg.wasm copies the whole input into wasm memory (~2 GB ceiling). */
|
|
141
|
+
const MAX_FFMPEG_INPUT_BYTES = 200 * 1024 * 1024;
|
|
27
142
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
143
|
+
* Per quality tier. `shortSide` caps the shorter display side, so portrait and
|
|
144
|
+
* landscape get the same box as on iOS. WebCodecs: that + a bitrate ceiling; the quantizer
|
|
145
|
+
* (constant 26, CRF-like) drives quality where the encoder honours it, but
|
|
146
|
+
* H.264 on most browsers is bitrate-driven, so `bitrate` is the real knob.
|
|
147
|
+
* ffmpeg: same short side, `crf` and `audioBitrate`. fps is capped to 30 at call
|
|
148
|
+
* time (never upsampled). On WebCodecs audio is left to mediabunny, which
|
|
149
|
+
* re-encodes to AAC for the MP4 container.
|
|
33
150
|
*/
|
|
34
|
-
const
|
|
35
|
-
VERY_HIGH: {
|
|
36
|
-
HIGH: {
|
|
37
|
-
MEDIUM: {
|
|
38
|
-
LOW: {
|
|
39
|
-
'360P': {
|
|
40
|
-
VERY_LOW: {
|
|
151
|
+
const QUALITY_LADDER = {
|
|
152
|
+
VERY_HIGH: { shortSide: 1080, bitrate: 4000000, crf: 23, audioBitrate: '128k' },
|
|
153
|
+
HIGH: { shortSide: 720, bitrate: 2000000, crf: 28, audioBitrate: '128k' },
|
|
154
|
+
MEDIUM: { shortSide: 540, bitrate: 1200000, crf: 30, audioBitrate: '96k' },
|
|
155
|
+
LOW: { shortSide: 480, bitrate: 1000000, crf: 32, audioBitrate: '96k' },
|
|
156
|
+
'360P': { shortSide: 360, bitrate: 700000, crf: 28, audioBitrate: '64k' },
|
|
157
|
+
VERY_LOW: { shortSide: 240, bitrate: 350000, crf: 35, audioBitrate: '48k' },
|
|
41
158
|
};
|
|
42
159
|
const WEBCODECS_QUANTIZER = 26;
|
|
160
|
+
// Never upscales. Mediabunny derives the other side from the aspect ratio and rounds both up to even.
|
|
161
|
+
const webCodecsSize = (displayWidth, displayHeight, shortSide) => {
|
|
162
|
+
const side = Math.min(shortSide, displayWidth, displayHeight);
|
|
163
|
+
return displayHeight > displayWidth ? { width: side } : { height: side };
|
|
164
|
+
};
|
|
165
|
+
// iw/ih are post-autorotate here: ffmpeg inserts the transpose ahead of -vf.
|
|
166
|
+
// Never upscales; trunc keeps an odd source side even for libx264.
|
|
167
|
+
const ffmpegScaleFilter = (shortSide) => `scale=w='if(gt(iw,ih),-2,min(${shortSide},trunc(iw/2)*2))':h='if(gt(iw,ih),min(${shortSide},trunc(ih/2)*2),-2)'`;
|
|
43
168
|
const FPS_CAP = 30;
|
|
169
|
+
const MIN_VIDEO_BITRATE = 200000;
|
|
170
|
+
// A lean source re-encoded at the tier bitrate comes out larger than it went in.
|
|
171
|
+
// The floor stops a broken source estimate from producing an unwatchable file.
|
|
172
|
+
const capBitrate = (target, sourceBitrate, floor, headroom = 1) => sourceBitrate && sourceBitrate > 0 ? Math.min(target, Math.max(floor, Math.round(sourceBitrate * headroom))) : target;
|
|
173
|
+
// H.264 needs about 1.5x the bitrate of these codecs for the same picture.
|
|
174
|
+
const codecHeadroom = (codec) => codec === 'hevc' || codec === 'vp9' || codec === 'av1' ? 1.5 : 1;
|
|
175
|
+
// Cap fps at 30, but never upsample. A failed probe leaves the source rate and bitrate untouched.
|
|
176
|
+
const probeVideoStats = async (track) => {
|
|
177
|
+
try {
|
|
178
|
+
const stats = await (track === null || track === void 0 ? void 0 : track.computePacketStats(100));
|
|
179
|
+
if (!stats)
|
|
180
|
+
return {};
|
|
181
|
+
return {
|
|
182
|
+
frameRate: stats.averagePacketRate > 0 ? Math.min(FPS_CAP, Math.round(stats.averagePacketRate)) : undefined,
|
|
183
|
+
bitrate: stats.averageBitrate,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (_a) {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
44
190
|
const CORE_BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
|
45
191
|
/**
|
|
46
192
|
* Cancelling means terminating the worker, which drops the loaded core with
|
|
@@ -73,28 +219,42 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
73
219
|
this.isLoaded = false;
|
|
74
220
|
/** The mediabunny conversion running now, so `cancel()` can abort it. */
|
|
75
221
|
this.activeConversion = null;
|
|
76
|
-
/** The job `compressVideo` is running right now,
|
|
77
|
-
this.
|
|
222
|
+
/** The job `compressVideo` is running right now; a fresh object per call, so two calls never look alike. */
|
|
223
|
+
this.currentJob = null;
|
|
78
224
|
this.cancelRequested = false;
|
|
225
|
+
/** Kept OPFS outputs by jobId, deleted by `finishUpload`. */
|
|
226
|
+
this.keptOutputs = new Map();
|
|
79
227
|
}
|
|
80
228
|
async initialize() {
|
|
81
|
-
//
|
|
82
|
-
// to warm. Eagerly loading the ffmpeg core here would pull ~30 MB for a
|
|
83
|
-
// path most compresses never take. The fallback lazy-loads its core on
|
|
84
|
-
// first use. Kept as a resolving no-op so callers that await it still pass.
|
|
229
|
+
// No-op: preloading the ~30 MB ffmpeg core would cost most compresses a download they never use.
|
|
85
230
|
return { success: true, message: 'ready' };
|
|
86
231
|
}
|
|
232
|
+
async finishUpload({ jobId }) {
|
|
233
|
+
const output = this.keptOutputs.get(jobId);
|
|
234
|
+
if (!output)
|
|
235
|
+
return { released: false };
|
|
236
|
+
this.keptOutputs.delete(jobId);
|
|
237
|
+
await output.discard();
|
|
238
|
+
return { released: true };
|
|
239
|
+
}
|
|
87
240
|
async compressVideo(options) {
|
|
88
241
|
var _a, _b;
|
|
89
|
-
|
|
242
|
+
const job = { id: (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default' };
|
|
243
|
+
if (this.currentJob)
|
|
244
|
+
this.stopEngines();
|
|
245
|
+
this.currentJob = job;
|
|
90
246
|
this.cancelRequested = false;
|
|
247
|
+
// A job the caller gave up on (e.g. a stall timeout) runs on until its next
|
|
248
|
+
// await returns. Once another job has started, the shared flags above speak
|
|
249
|
+
// for that job, not this one, so a superseded job counts as cancelled.
|
|
250
|
+
const isSuperseded = () => this.currentJob !== job;
|
|
91
251
|
// Checked at every stage boundary, not just around the encode: cancelling
|
|
92
252
|
// an engine only reaches work already handed to it, so a cancel during the
|
|
93
253
|
// source fetch or before init has nothing to interrupt and would otherwise
|
|
94
254
|
// let the compress carry on as if nothing had happened.
|
|
95
255
|
const throwIfCancelled = () => {
|
|
96
|
-
if (this.cancelRequested)
|
|
97
|
-
throw
|
|
256
|
+
if (this.cancelRequested || isSuperseded())
|
|
257
|
+
throw cancelledError();
|
|
98
258
|
};
|
|
99
259
|
try {
|
|
100
260
|
throwIfCancelled();
|
|
@@ -103,18 +263,22 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
103
263
|
const quality = (_b = options.quality) !== null && _b !== void 0 ? _b : 'MEDIUM';
|
|
104
264
|
if (await this.canUseWebCodecs(quality)) {
|
|
105
265
|
try {
|
|
106
|
-
const result = await this.compressWithWebCodecs(blob, quality, throwIfCancelled);
|
|
266
|
+
const result = await this.compressWithWebCodecs(blob, quality, job.id, throwIfCancelled);
|
|
107
267
|
if (result)
|
|
108
268
|
return result;
|
|
109
|
-
// null → conversion invalid / tracks discarded → fall back to ffmpeg.
|
|
110
269
|
}
|
|
111
270
|
catch (e) {
|
|
112
271
|
if ((e === null || e === void 0 ? void 0 : e.code) === CANCELLED)
|
|
113
272
|
throw e; // user cancel: never fall back
|
|
273
|
+
// Not to ffmpeg either: it needs even more room, and its size cap would misreport this as TOO_LARGE_FOR_WEB.
|
|
274
|
+
if (isQuotaError(e))
|
|
275
|
+
throw new CompressError(STORAGE_FULL, 'Không đủ dung lượng lưu trữ để nén video');
|
|
114
276
|
console.warn('[compress] WebCodecs failed, falling back to ffmpeg:', e);
|
|
115
|
-
throwIfCancelled();
|
|
116
277
|
}
|
|
117
278
|
}
|
|
279
|
+
throwIfCancelled();
|
|
280
|
+
if (blob.size > MAX_FFMPEG_INPUT_BYTES)
|
|
281
|
+
throw new CompressError(TOO_LARGE_FOR_WEB, 'Video quá lớn để nén trên trình duyệt này');
|
|
118
282
|
return await this.compressWithFfmpeg(blob, quality, throwIfCancelled);
|
|
119
283
|
}
|
|
120
284
|
catch (error) {
|
|
@@ -127,9 +291,11 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
127
291
|
throw error;
|
|
128
292
|
}
|
|
129
293
|
finally {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
294
|
+
if (!isSuperseded()) {
|
|
295
|
+
this.currentJob = null;
|
|
296
|
+
this.cancelRequested = false;
|
|
297
|
+
this.activeConversion = null;
|
|
298
|
+
}
|
|
133
299
|
}
|
|
134
300
|
}
|
|
135
301
|
/**
|
|
@@ -137,30 +303,33 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
137
303
|
* so acting on both is safe.
|
|
138
304
|
*
|
|
139
305
|
* WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
|
|
140
|
-
* with `ConversionCanceledError`, surfaced as a `
|
|
306
|
+
* with `ConversionCanceledError`, surfaced as a CANCELLED `CompressError` below.
|
|
141
307
|
* ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
|
|
142
308
|
* message poll while encoding, so `worker.terminate()` is the only way to
|
|
143
309
|
* stop it — its cost is the loaded core, which is why the core URLs are
|
|
144
310
|
* cached above.
|
|
145
311
|
*/
|
|
146
312
|
async cancel(options) {
|
|
147
|
-
|
|
148
|
-
if (!this.currentJobId)
|
|
313
|
+
if (!this.currentJob)
|
|
149
314
|
return { cancelled: false };
|
|
150
|
-
if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.
|
|
315
|
+
if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJob.id)
|
|
151
316
|
return { cancelled: false };
|
|
152
317
|
// Set even when neither engine exists yet: the job may still be fetching
|
|
153
318
|
// the source or inside loadFFmpeg, which read this flag at their boundaries.
|
|
154
319
|
this.cancelRequested = true;
|
|
320
|
+
this.stopEngines();
|
|
321
|
+
return { cancelled: true };
|
|
322
|
+
}
|
|
323
|
+
stopEngines() {
|
|
324
|
+
var _a;
|
|
155
325
|
const conversion = this.activeConversion;
|
|
156
326
|
this.activeConversion = null;
|
|
157
|
-
// Fire-and-forget: the
|
|
327
|
+
// Fire-and-forget: the CANCELLED error surfaces from the execute() rejection,
|
|
158
328
|
// not from here; awaiting would only make cancel() slower.
|
|
159
329
|
conversion === null || conversion === void 0 ? void 0 : conversion.cancel().catch(() => undefined);
|
|
160
330
|
(_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
|
|
161
331
|
this.ffmpeg = null;
|
|
162
332
|
this.isLoaded = false;
|
|
163
|
-
return { cancelled: true };
|
|
164
333
|
}
|
|
165
334
|
async fetchSourceBlob(sourcePath) {
|
|
166
335
|
try {
|
|
@@ -174,8 +343,9 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
174
343
|
}
|
|
175
344
|
async canUseWebCodecs(quality) {
|
|
176
345
|
try {
|
|
177
|
-
const { bitrate } =
|
|
346
|
+
const { bitrate } = QUALITY_LADDER[quality];
|
|
178
347
|
return await mediabunny.canEncodeVideo('avc', {
|
|
348
|
+
hardwareAcceleration: 'no-preference',
|
|
179
349
|
quality: new mediabunny.Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }),
|
|
180
350
|
});
|
|
181
351
|
}
|
|
@@ -183,71 +353,104 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
183
353
|
return false;
|
|
184
354
|
}
|
|
185
355
|
}
|
|
186
|
-
async compressWithWebCodecs(blob, quality, throwIfCancelled) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// A failed probe just omits frameRate, leaving the source rate untouched.
|
|
192
|
-
let frameRate;
|
|
356
|
+
async compressWithWebCodecs(blob, quality, jobId, throwIfCancelled) {
|
|
357
|
+
const opfs = await createOpfsOutput();
|
|
358
|
+
console.debug(`[compress] engine=webcodecs target=${opfs ? 'opfs' : 'buffer'}`);
|
|
359
|
+
let output;
|
|
360
|
+
let kept = false;
|
|
193
361
|
try {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
362
|
+
const input = new mediabunny.Input({ formats: mediabunny.ALL_FORMATS, source: new mediabunny.BlobSource(blob) });
|
|
363
|
+
output = new mediabunny.Output({
|
|
364
|
+
format: new mediabunny.Mp4OutputFormat(),
|
|
365
|
+
target: opfs ? new mediabunny.StreamTarget(opfs.writable, { chunked: true }) : new mediabunny.BufferTarget(),
|
|
366
|
+
});
|
|
367
|
+
if (!(await this.runConversion(input, output, QUALITY_LADDER[quality], throwIfCancelled)))
|
|
368
|
+
return null;
|
|
369
|
+
let outBlob;
|
|
370
|
+
if (opfs) {
|
|
371
|
+
outBlob = await opfs.read();
|
|
200
372
|
}
|
|
373
|
+
else {
|
|
374
|
+
const buffer = output.target.buffer;
|
|
375
|
+
if (!buffer)
|
|
376
|
+
throw new Error('WebCodecs produced no output');
|
|
377
|
+
outBlob = new Blob([buffer], { type: 'video/mp4' });
|
|
378
|
+
}
|
|
379
|
+
kept = true;
|
|
380
|
+
if (opfs)
|
|
381
|
+
this.keptOutputs.set(jobId, opfs);
|
|
382
|
+
return { success: true, destPath: URL.createObjectURL(outBlob) };
|
|
201
383
|
}
|
|
202
|
-
|
|
203
|
-
|
|
384
|
+
finally {
|
|
385
|
+
if (!kept) {
|
|
386
|
+
// Closes the writable first, which otherwise keeps the entry locked against removal.
|
|
387
|
+
await (output === null || output === void 0 ? void 0 : output.cancel().catch(() => undefined));
|
|
388
|
+
await (opfs === null || opfs === void 0 ? void 0 : opfs.discard());
|
|
389
|
+
}
|
|
204
390
|
}
|
|
391
|
+
}
|
|
392
|
+
/** False when mediabunny can't convert this input. */
|
|
393
|
+
async runConversion(input, output, { shortSide, bitrate }, throwIfCancelled) {
|
|
394
|
+
const track = await input.getPrimaryVideoTrack();
|
|
395
|
+
const { frameRate, bitrate: sourceBitrate } = await probeVideoStats(track);
|
|
396
|
+
const size = track ? webCodecsSize(await track.getDisplayWidth(), await track.getDisplayHeight(), shortSide) : {};
|
|
205
397
|
throwIfCancelled();
|
|
206
398
|
const conversion = await mediabunny.Conversion.init({
|
|
207
399
|
input,
|
|
208
400
|
output,
|
|
209
|
-
video:
|
|
401
|
+
video: {
|
|
402
|
+
...size,
|
|
403
|
+
quality: new mediabunny.Quality({
|
|
404
|
+
quantizer: WEBCODECS_QUANTIZER,
|
|
405
|
+
bitrate: capBitrate(bitrate, sourceBitrate, MIN_VIDEO_BITRATE, codecHeadroom(track === null || track === void 0 ? void 0 : track.codec)),
|
|
406
|
+
}),
|
|
407
|
+
// Not 'prefer-hardware': machines without a hardware H.264 encoder
|
|
408
|
+
// (Linux, VMs, headless) then fail mid-encode and drop to ffmpeg.wasm.
|
|
409
|
+
// The browser still picks the hardware encoder when it has one, and the
|
|
410
|
+
// canEncodeVideo probe asks the same question, so probe and encode agree.
|
|
411
|
+
hardwareAcceleration: 'no-preference',
|
|
412
|
+
...(frameRate ? { frameRate } : {}),
|
|
413
|
+
},
|
|
414
|
+
// The default copies the input's raw udta metadata tags, GPS location included.
|
|
415
|
+
tags: {},
|
|
210
416
|
});
|
|
211
417
|
if (!conversion.isValid) {
|
|
212
418
|
console.warn('[compress] WebCodecs conversion invalid:', conversion.discardedTracks.map((t) => t.reason));
|
|
213
|
-
return
|
|
419
|
+
return false;
|
|
214
420
|
}
|
|
421
|
+
throwIfCancelled();
|
|
215
422
|
this.activeConversion = conversion;
|
|
216
423
|
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
217
424
|
conversion.onProgress = (progress) => {
|
|
218
|
-
this.
|
|
425
|
+
if (this.activeConversion === conversion)
|
|
426
|
+
this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
|
|
219
427
|
};
|
|
220
|
-
throwIfCancelled();
|
|
221
428
|
try {
|
|
222
429
|
await conversion.execute();
|
|
223
430
|
}
|
|
224
431
|
catch (e) {
|
|
225
432
|
if (this.cancelRequested || e instanceof mediabunny.ConversionCanceledError)
|
|
226
|
-
throw
|
|
433
|
+
throw cancelledError();
|
|
227
434
|
throw e;
|
|
228
435
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
throw new Error('WebCodecs produced no output');
|
|
232
|
-
const outBlob = new Blob([buffer], { type: 'video/mp4' });
|
|
233
|
-
return { success: true, destPath: URL.createObjectURL(outBlob) };
|
|
436
|
+
throwIfCancelled();
|
|
437
|
+
return true;
|
|
234
438
|
}
|
|
235
|
-
async loadFFmpeg() {
|
|
439
|
+
async loadFFmpeg(throwIfCancelled) {
|
|
236
440
|
if (this.isLoaded && this.ffmpeg)
|
|
237
441
|
return;
|
|
442
|
+
throwIfCancelled();
|
|
238
443
|
this.notifyListeners('onProgress', { status: 'loading_core', percent: 0 });
|
|
239
|
-
//
|
|
240
|
-
// wanted. Assigning `this.ffmpeg` up front and setting `isLoaded` after the
|
|
241
|
-
// await left a window where a cancel could null the instance and the load,
|
|
242
|
-
// finishing a moment later, would set `isLoaded = true` on nothing at all —
|
|
243
|
-
// after which every compressVideo threw 'FFmpeg failed to load' for the
|
|
244
|
-
// rest of the page's life.
|
|
444
|
+
// Published only once loaded and still wanted, so a cancel mid-load can't leave `isLoaded` set on a dropped instance.
|
|
245
445
|
const instance = new ffmpeg.FFmpeg();
|
|
246
446
|
instance.on('progress', ({ progress }) => {
|
|
247
|
-
|
|
447
|
+
// Once published the instance serves every later job; before that, or
|
|
448
|
+
// after cancel() dropped it, its events belong to no job.
|
|
449
|
+
if (this.ffmpeg === instance)
|
|
450
|
+
this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
|
|
248
451
|
});
|
|
249
452
|
instance.on('log', ({ message }) => {
|
|
250
|
-
console.
|
|
453
|
+
console.debug('[FFmpeg Log]', message);
|
|
251
454
|
});
|
|
252
455
|
try {
|
|
253
456
|
await instance.load(await loadCoreURLs());
|
|
@@ -259,16 +462,19 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
259
462
|
}
|
|
260
463
|
// Cancelled while this was loading. Terminate what we just built rather
|
|
261
464
|
// than publish it — the caller asked for none of it.
|
|
262
|
-
|
|
465
|
+
try {
|
|
466
|
+
throwIfCancelled();
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
263
469
|
instance.terminate();
|
|
264
|
-
throw
|
|
470
|
+
throw e;
|
|
265
471
|
}
|
|
266
472
|
this.ffmpeg = instance;
|
|
267
473
|
this.isLoaded = true;
|
|
268
474
|
}
|
|
269
475
|
async compressWithFfmpeg(blob, quality, throwIfCancelled) {
|
|
270
476
|
if (!this.isLoaded) {
|
|
271
|
-
await this.loadFFmpeg();
|
|
477
|
+
await this.loadFFmpeg(throwIfCancelled);
|
|
272
478
|
}
|
|
273
479
|
throwIfCancelled();
|
|
274
480
|
if (!this.ffmpeg) {
|
|
@@ -276,72 +482,39 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
276
482
|
}
|
|
277
483
|
// Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after any
|
|
278
484
|
// of the awaits below can hand back null on a reference the compiler
|
|
279
|
-
// narrowed here — surfacing a cancel as a TypeError, not a
|
|
485
|
+
// narrowed here — surfacing a cancel as a TypeError, not a CANCELLED error.
|
|
280
486
|
const ffmpeg = this.ffmpeg;
|
|
487
|
+
console.debug('[compress] engine=ffmpeg');
|
|
281
488
|
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
282
489
|
const inputFileName = 'input.mp4';
|
|
283
490
|
const outputFileName = 'output.mp4';
|
|
284
|
-
|
|
285
|
-
let scale = '';
|
|
286
|
-
let audioBitrate = '128k';
|
|
287
|
-
switch (quality) {
|
|
288
|
-
case 'VERY_HIGH':
|
|
289
|
-
crf = '23';
|
|
290
|
-
scale = '-vf scale=-2:1080';
|
|
291
|
-
audioBitrate = '128k';
|
|
292
|
-
break;
|
|
293
|
-
case 'HIGH':
|
|
294
|
-
crf = '28';
|
|
295
|
-
scale = '-vf scale=-2:720';
|
|
296
|
-
audioBitrate = '128k';
|
|
297
|
-
break;
|
|
298
|
-
case 'MEDIUM':
|
|
299
|
-
crf = '30';
|
|
300
|
-
scale = '-vf scale=-2:540';
|
|
301
|
-
audioBitrate = '96k';
|
|
302
|
-
break;
|
|
303
|
-
case 'LOW':
|
|
304
|
-
crf = '32';
|
|
305
|
-
scale = '-vf scale=-2:480';
|
|
306
|
-
audioBitrate = '96k';
|
|
307
|
-
break;
|
|
308
|
-
case '360P':
|
|
309
|
-
crf = '28';
|
|
310
|
-
scale = '-vf scale=-2:360';
|
|
311
|
-
audioBitrate = '64k';
|
|
312
|
-
break;
|
|
313
|
-
case 'VERY_LOW':
|
|
314
|
-
crf = '35';
|
|
315
|
-
scale = '-vf scale=-2:240';
|
|
316
|
-
audioBitrate = '48k';
|
|
317
|
-
break;
|
|
318
|
-
}
|
|
491
|
+
const { shortSide, crf, audioBitrate } = QUALITY_LADDER[quality];
|
|
319
492
|
const inputData = new Uint8Array(await blob.arrayBuffer());
|
|
320
493
|
try {
|
|
321
494
|
await ffmpeg.writeFile(inputFileName, inputData);
|
|
322
495
|
}
|
|
323
496
|
catch (err) {
|
|
324
|
-
|
|
325
|
-
throw new CancelledError();
|
|
497
|
+
throwIfCancelled();
|
|
326
498
|
console.error('[FFmpeg] Error writing to FS:', err);
|
|
327
499
|
throw new Error('Could not write to FFmpeg FS');
|
|
328
500
|
}
|
|
329
501
|
throwIfCancelled();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
502
|
+
// ffmpeg copies global metadata by default, including the GPS location (©xyz / loci).
|
|
503
|
+
// prettier-ignore
|
|
504
|
+
const args = [
|
|
505
|
+
'-i', inputFileName, '-map_metadata', '-1',
|
|
506
|
+
'-c:v', 'libx264', '-preset', 'superfast', '-crf', String(crf),
|
|
507
|
+
'-vf', ffmpegScaleFilter(shortSide),
|
|
508
|
+
'-c:a', 'aac', '-b:a', audioBitrate,
|
|
509
|
+
outputFileName,
|
|
510
|
+
];
|
|
337
511
|
try {
|
|
338
512
|
await ffmpeg.exec(args);
|
|
339
513
|
}
|
|
340
514
|
catch (err) {
|
|
341
515
|
// terminate() rejects every in-flight call, so a cancel arrives here
|
|
342
516
|
// looking like a generic failure unless we name it.
|
|
343
|
-
|
|
344
|
-
throw new CancelledError();
|
|
517
|
+
throwIfCancelled();
|
|
345
518
|
console.error('[FFmpeg] Error executing command:', err);
|
|
346
519
|
throw new Error('FFmpeg execution failed');
|
|
347
520
|
}
|
|
@@ -351,8 +524,7 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
351
524
|
data = await ffmpeg.readFile(outputFileName);
|
|
352
525
|
}
|
|
353
526
|
catch (err) {
|
|
354
|
-
|
|
355
|
-
throw new CancelledError();
|
|
527
|
+
throwIfCancelled();
|
|
356
528
|
console.error('[FFmpeg] Error reading output file:', err);
|
|
357
529
|
throw new Error('Could not read FFmpeg output');
|
|
358
530
|
}
|
|
@@ -365,13 +537,19 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
365
537
|
|
|
366
538
|
var web = /*#__PURE__*/Object.freeze({
|
|
367
539
|
__proto__: null,
|
|
368
|
-
NativeVideoCompressorWeb: NativeVideoCompressorWeb
|
|
540
|
+
NativeVideoCompressorWeb: NativeVideoCompressorWeb,
|
|
541
|
+
capBitrate: capBitrate,
|
|
542
|
+
codecHeadroom: codecHeadroom,
|
|
543
|
+
ffmpegScaleFilter: ffmpegScaleFilter,
|
|
544
|
+
webCodecsSize: webCodecsSize
|
|
369
545
|
});
|
|
370
546
|
|
|
371
547
|
exports.CANCELLED = CANCELLED;
|
|
372
548
|
exports.NativeVideoCompressor = NativeVideoCompressor;
|
|
549
|
+
exports.STORAGE_FULL = STORAGE_FULL;
|
|
550
|
+
exports.TOO_LARGE_FOR_WEB = TOO_LARGE_FOR_WEB;
|
|
373
551
|
|
|
374
552
|
return exports;
|
|
375
553
|
|
|
376
|
-
})({}, capacitorExports,
|
|
554
|
+
})({}, capacitorExports, FFmpegWASM, FFmpegUtil, Mediabunny);
|
|
377
555
|
//# sourceMappingURL=plugin.js.map
|