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/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,34 +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
|
}
|
|
87
|
-
async finishUpload() {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 };
|
|
92
239
|
}
|
|
93
240
|
async compressVideo(options) {
|
|
94
241
|
var _a, _b;
|
|
95
|
-
|
|
242
|
+
const job = { id: (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default' };
|
|
243
|
+
if (this.currentJob)
|
|
244
|
+
this.stopEngines();
|
|
245
|
+
this.currentJob = job;
|
|
96
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;
|
|
97
251
|
// Checked at every stage boundary, not just around the encode: cancelling
|
|
98
252
|
// an engine only reaches work already handed to it, so a cancel during the
|
|
99
253
|
// source fetch or before init has nothing to interrupt and would otherwise
|
|
100
254
|
// let the compress carry on as if nothing had happened.
|
|
101
255
|
const throwIfCancelled = () => {
|
|
102
|
-
if (this.cancelRequested)
|
|
103
|
-
throw
|
|
256
|
+
if (this.cancelRequested || isSuperseded())
|
|
257
|
+
throw cancelledError();
|
|
104
258
|
};
|
|
105
259
|
try {
|
|
106
260
|
throwIfCancelled();
|
|
@@ -109,18 +263,22 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
109
263
|
const quality = (_b = options.quality) !== null && _b !== void 0 ? _b : 'MEDIUM';
|
|
110
264
|
if (await this.canUseWebCodecs(quality)) {
|
|
111
265
|
try {
|
|
112
|
-
const result = await this.compressWithWebCodecs(blob, quality, throwIfCancelled);
|
|
266
|
+
const result = await this.compressWithWebCodecs(blob, quality, job.id, throwIfCancelled);
|
|
113
267
|
if (result)
|
|
114
268
|
return result;
|
|
115
|
-
// null → conversion invalid / tracks discarded → fall back to ffmpeg.
|
|
116
269
|
}
|
|
117
270
|
catch (e) {
|
|
118
271
|
if ((e === null || e === void 0 ? void 0 : e.code) === CANCELLED)
|
|
119
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');
|
|
120
276
|
console.warn('[compress] WebCodecs failed, falling back to ffmpeg:', e);
|
|
121
|
-
throwIfCancelled();
|
|
122
277
|
}
|
|
123
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');
|
|
124
282
|
return await this.compressWithFfmpeg(blob, quality, throwIfCancelled);
|
|
125
283
|
}
|
|
126
284
|
catch (error) {
|
|
@@ -133,9 +291,11 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
133
291
|
throw error;
|
|
134
292
|
}
|
|
135
293
|
finally {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
294
|
+
if (!isSuperseded()) {
|
|
295
|
+
this.currentJob = null;
|
|
296
|
+
this.cancelRequested = false;
|
|
297
|
+
this.activeConversion = null;
|
|
298
|
+
}
|
|
139
299
|
}
|
|
140
300
|
}
|
|
141
301
|
/**
|
|
@@ -143,30 +303,33 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
143
303
|
* so acting on both is safe.
|
|
144
304
|
*
|
|
145
305
|
* WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
|
|
146
|
-
* with `ConversionCanceledError`, surfaced as a `
|
|
306
|
+
* with `ConversionCanceledError`, surfaced as a CANCELLED `CompressError` below.
|
|
147
307
|
* ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
|
|
148
308
|
* message poll while encoding, so `worker.terminate()` is the only way to
|
|
149
309
|
* stop it — its cost is the loaded core, which is why the core URLs are
|
|
150
310
|
* cached above.
|
|
151
311
|
*/
|
|
152
312
|
async cancel(options) {
|
|
153
|
-
|
|
154
|
-
if (!this.currentJobId)
|
|
313
|
+
if (!this.currentJob)
|
|
155
314
|
return { cancelled: false };
|
|
156
|
-
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)
|
|
157
316
|
return { cancelled: false };
|
|
158
317
|
// Set even when neither engine exists yet: the job may still be fetching
|
|
159
318
|
// the source or inside loadFFmpeg, which read this flag at their boundaries.
|
|
160
319
|
this.cancelRequested = true;
|
|
320
|
+
this.stopEngines();
|
|
321
|
+
return { cancelled: true };
|
|
322
|
+
}
|
|
323
|
+
stopEngines() {
|
|
324
|
+
var _a;
|
|
161
325
|
const conversion = this.activeConversion;
|
|
162
326
|
this.activeConversion = null;
|
|
163
|
-
// Fire-and-forget: the
|
|
327
|
+
// Fire-and-forget: the CANCELLED error surfaces from the execute() rejection,
|
|
164
328
|
// not from here; awaiting would only make cancel() slower.
|
|
165
329
|
conversion === null || conversion === void 0 ? void 0 : conversion.cancel().catch(() => undefined);
|
|
166
330
|
(_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
|
|
167
331
|
this.ffmpeg = null;
|
|
168
332
|
this.isLoaded = false;
|
|
169
|
-
return { cancelled: true };
|
|
170
333
|
}
|
|
171
334
|
async fetchSourceBlob(sourcePath) {
|
|
172
335
|
try {
|
|
@@ -180,8 +343,9 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
180
343
|
}
|
|
181
344
|
async canUseWebCodecs(quality) {
|
|
182
345
|
try {
|
|
183
|
-
const { bitrate } =
|
|
346
|
+
const { bitrate } = QUALITY_LADDER[quality];
|
|
184
347
|
return await mediabunny.canEncodeVideo('avc', {
|
|
348
|
+
hardwareAcceleration: 'no-preference',
|
|
185
349
|
quality: new mediabunny.Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }),
|
|
186
350
|
});
|
|
187
351
|
}
|
|
@@ -189,71 +353,104 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
189
353
|
return false;
|
|
190
354
|
}
|
|
191
355
|
}
|
|
192
|
-
async compressWithWebCodecs(blob, quality, throwIfCancelled) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// A failed probe just omits frameRate, leaving the source rate untouched.
|
|
198
|
-
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;
|
|
199
361
|
try {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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();
|
|
206
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) };
|
|
207
383
|
}
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
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) : {};
|
|
211
397
|
throwIfCancelled();
|
|
212
398
|
const conversion = await mediabunny.Conversion.init({
|
|
213
399
|
input,
|
|
214
400
|
output,
|
|
215
|
-
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: {},
|
|
216
416
|
});
|
|
217
417
|
if (!conversion.isValid) {
|
|
218
418
|
console.warn('[compress] WebCodecs conversion invalid:', conversion.discardedTracks.map((t) => t.reason));
|
|
219
|
-
return
|
|
419
|
+
return false;
|
|
220
420
|
}
|
|
421
|
+
throwIfCancelled();
|
|
221
422
|
this.activeConversion = conversion;
|
|
222
423
|
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
223
424
|
conversion.onProgress = (progress) => {
|
|
224
|
-
this.
|
|
425
|
+
if (this.activeConversion === conversion)
|
|
426
|
+
this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
|
|
225
427
|
};
|
|
226
|
-
throwIfCancelled();
|
|
227
428
|
try {
|
|
228
429
|
await conversion.execute();
|
|
229
430
|
}
|
|
230
431
|
catch (e) {
|
|
231
432
|
if (this.cancelRequested || e instanceof mediabunny.ConversionCanceledError)
|
|
232
|
-
throw
|
|
433
|
+
throw cancelledError();
|
|
233
434
|
throw e;
|
|
234
435
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
throw new Error('WebCodecs produced no output');
|
|
238
|
-
const outBlob = new Blob([buffer], { type: 'video/mp4' });
|
|
239
|
-
return { success: true, destPath: URL.createObjectURL(outBlob) };
|
|
436
|
+
throwIfCancelled();
|
|
437
|
+
return true;
|
|
240
438
|
}
|
|
241
|
-
async loadFFmpeg() {
|
|
439
|
+
async loadFFmpeg(throwIfCancelled) {
|
|
242
440
|
if (this.isLoaded && this.ffmpeg)
|
|
243
441
|
return;
|
|
442
|
+
throwIfCancelled();
|
|
244
443
|
this.notifyListeners('onProgress', { status: 'loading_core', percent: 0 });
|
|
245
|
-
//
|
|
246
|
-
// wanted. Assigning `this.ffmpeg` up front and setting `isLoaded` after the
|
|
247
|
-
// await left a window where a cancel could null the instance and the load,
|
|
248
|
-
// finishing a moment later, would set `isLoaded = true` on nothing at all —
|
|
249
|
-
// after which every compressVideo threw 'FFmpeg failed to load' for the
|
|
250
|
-
// 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.
|
|
251
445
|
const instance = new ffmpeg.FFmpeg();
|
|
252
446
|
instance.on('progress', ({ progress }) => {
|
|
253
|
-
|
|
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) });
|
|
254
451
|
});
|
|
255
452
|
instance.on('log', ({ message }) => {
|
|
256
|
-
console.
|
|
453
|
+
console.debug('[FFmpeg Log]', message);
|
|
257
454
|
});
|
|
258
455
|
try {
|
|
259
456
|
await instance.load(await loadCoreURLs());
|
|
@@ -265,16 +462,19 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
265
462
|
}
|
|
266
463
|
// Cancelled while this was loading. Terminate what we just built rather
|
|
267
464
|
// than publish it — the caller asked for none of it.
|
|
268
|
-
|
|
465
|
+
try {
|
|
466
|
+
throwIfCancelled();
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
269
469
|
instance.terminate();
|
|
270
|
-
throw
|
|
470
|
+
throw e;
|
|
271
471
|
}
|
|
272
472
|
this.ffmpeg = instance;
|
|
273
473
|
this.isLoaded = true;
|
|
274
474
|
}
|
|
275
475
|
async compressWithFfmpeg(blob, quality, throwIfCancelled) {
|
|
276
476
|
if (!this.isLoaded) {
|
|
277
|
-
await this.loadFFmpeg();
|
|
477
|
+
await this.loadFFmpeg(throwIfCancelled);
|
|
278
478
|
}
|
|
279
479
|
throwIfCancelled();
|
|
280
480
|
if (!this.ffmpeg) {
|
|
@@ -282,72 +482,39 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
282
482
|
}
|
|
283
483
|
// Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after any
|
|
284
484
|
// of the awaits below can hand back null on a reference the compiler
|
|
285
|
-
// narrowed here — surfacing a cancel as a TypeError, not a
|
|
485
|
+
// narrowed here — surfacing a cancel as a TypeError, not a CANCELLED error.
|
|
286
486
|
const ffmpeg = this.ffmpeg;
|
|
487
|
+
console.debug('[compress] engine=ffmpeg');
|
|
287
488
|
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
288
489
|
const inputFileName = 'input.mp4';
|
|
289
490
|
const outputFileName = 'output.mp4';
|
|
290
|
-
|
|
291
|
-
let scale = '';
|
|
292
|
-
let audioBitrate = '128k';
|
|
293
|
-
switch (quality) {
|
|
294
|
-
case 'VERY_HIGH':
|
|
295
|
-
crf = '23';
|
|
296
|
-
scale = '-vf scale=-2:1080';
|
|
297
|
-
audioBitrate = '128k';
|
|
298
|
-
break;
|
|
299
|
-
case 'HIGH':
|
|
300
|
-
crf = '28';
|
|
301
|
-
scale = '-vf scale=-2:720';
|
|
302
|
-
audioBitrate = '128k';
|
|
303
|
-
break;
|
|
304
|
-
case 'MEDIUM':
|
|
305
|
-
crf = '30';
|
|
306
|
-
scale = '-vf scale=-2:540';
|
|
307
|
-
audioBitrate = '96k';
|
|
308
|
-
break;
|
|
309
|
-
case 'LOW':
|
|
310
|
-
crf = '32';
|
|
311
|
-
scale = '-vf scale=-2:480';
|
|
312
|
-
audioBitrate = '96k';
|
|
313
|
-
break;
|
|
314
|
-
case '360P':
|
|
315
|
-
crf = '28';
|
|
316
|
-
scale = '-vf scale=-2:360';
|
|
317
|
-
audioBitrate = '64k';
|
|
318
|
-
break;
|
|
319
|
-
case 'VERY_LOW':
|
|
320
|
-
crf = '35';
|
|
321
|
-
scale = '-vf scale=-2:240';
|
|
322
|
-
audioBitrate = '48k';
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
491
|
+
const { shortSide, crf, audioBitrate } = QUALITY_LADDER[quality];
|
|
325
492
|
const inputData = new Uint8Array(await blob.arrayBuffer());
|
|
326
493
|
try {
|
|
327
494
|
await ffmpeg.writeFile(inputFileName, inputData);
|
|
328
495
|
}
|
|
329
496
|
catch (err) {
|
|
330
|
-
|
|
331
|
-
throw new CancelledError();
|
|
497
|
+
throwIfCancelled();
|
|
332
498
|
console.error('[FFmpeg] Error writing to FS:', err);
|
|
333
499
|
throw new Error('Could not write to FFmpeg FS');
|
|
334
500
|
}
|
|
335
501
|
throwIfCancelled();
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
];
|
|
343
511
|
try {
|
|
344
512
|
await ffmpeg.exec(args);
|
|
345
513
|
}
|
|
346
514
|
catch (err) {
|
|
347
515
|
// terminate() rejects every in-flight call, so a cancel arrives here
|
|
348
516
|
// looking like a generic failure unless we name it.
|
|
349
|
-
|
|
350
|
-
throw new CancelledError();
|
|
517
|
+
throwIfCancelled();
|
|
351
518
|
console.error('[FFmpeg] Error executing command:', err);
|
|
352
519
|
throw new Error('FFmpeg execution failed');
|
|
353
520
|
}
|
|
@@ -357,8 +524,7 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
357
524
|
data = await ffmpeg.readFile(outputFileName);
|
|
358
525
|
}
|
|
359
526
|
catch (err) {
|
|
360
|
-
|
|
361
|
-
throw new CancelledError();
|
|
527
|
+
throwIfCancelled();
|
|
362
528
|
console.error('[FFmpeg] Error reading output file:', err);
|
|
363
529
|
throw new Error('Could not read FFmpeg output');
|
|
364
530
|
}
|
|
@@ -371,13 +537,19 @@ var capacitorNativeVideoCompressor = (function (exports, core, ffmpeg, util, med
|
|
|
371
537
|
|
|
372
538
|
var web = /*#__PURE__*/Object.freeze({
|
|
373
539
|
__proto__: null,
|
|
374
|
-
NativeVideoCompressorWeb: NativeVideoCompressorWeb
|
|
540
|
+
NativeVideoCompressorWeb: NativeVideoCompressorWeb,
|
|
541
|
+
capBitrate: capBitrate,
|
|
542
|
+
codecHeadroom: codecHeadroom,
|
|
543
|
+
ffmpegScaleFilter: ffmpegScaleFilter,
|
|
544
|
+
webCodecsSize: webCodecsSize
|
|
375
545
|
});
|
|
376
546
|
|
|
377
547
|
exports.CANCELLED = CANCELLED;
|
|
378
548
|
exports.NativeVideoCompressor = NativeVideoCompressor;
|
|
549
|
+
exports.STORAGE_FULL = STORAGE_FULL;
|
|
550
|
+
exports.TOO_LARGE_FOR_WEB = TOO_LARGE_FOR_WEB;
|
|
379
551
|
|
|
380
552
|
return exports;
|
|
381
553
|
|
|
382
|
-
})({}, capacitorExports,
|
|
554
|
+
})({}, capacitorExports, FFmpegWASM, FFmpegUtil, Mediabunny);
|
|
383
555
|
//# sourceMappingURL=plugin.js.map
|