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/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
- * Rejection shape for a cancelled compress. `code` matches what `call.reject`
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 — and on both web engines below.
126
+ * check works on every platform and on both web engines below.
19
127
  */
20
- class CancelledError extends Error {
21
- constructor() {
22
- super('Đã hủy nén');
23
- this.code = CANCELLED;
24
- this.name = 'CancelledError';
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
- * WebCodecs target per quality tier: cap height + a bitrate ceiling. The
29
- * quantizer (constant 26, CRF-like) drives quality where the encoder honours
30
- * it; H.264 on most browsers is bitrate-driven, so `bitrate` is the real knob.
31
- * fps is capped to 30 at call time (never upsampled). Audio is left to
32
- * mediabunny, which re-encodes to AAC for the MP4 container.
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 WEBCODECS_LADDER = {
35
- VERY_HIGH: { height: 1080, bitrate: 4000000 },
36
- HIGH: { height: 720, bitrate: 2000000 },
37
- MEDIUM: { height: 540, bitrate: 1200000 },
38
- LOW: { height: 480, bitrate: 1000000 },
39
- '360P': { height: 360, bitrate: 700000 },
40
- VERY_LOW: { height: 240, bitrate: 350000 },
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, for `cancel({ jobId })`. */
77
- this.currentJobId = null;
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
- // WebCodecs (the primary path) needs no core download, so there is nothing
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
- this.currentJobId = (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default';
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 new CancelledError();
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
- this.currentJobId = null;
131
- this.cancelRequested = false;
132
- this.activeConversion = null;
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 `CancelledError` below.
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
- var _a;
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.currentJobId)
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 CancelledError surfaces from the execute() rejection,
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 } = WEBCODECS_LADDER[quality];
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 { height, bitrate } = WEBCODECS_LADDER[quality];
188
- const input = new mediabunny.Input({ formats: mediabunny.ALL_FORMATS, source: new mediabunny.BlobSource(blob) });
189
- const output = new mediabunny.Output({ format: new mediabunny.Mp4OutputFormat(), target: new mediabunny.BufferTarget() });
190
- // Cap fps at 30, but never upsample: probe the source and take the min.
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 track = await input.getPrimaryVideoTrack();
195
- if (track) {
196
- const stats = await track.computePacketStats(100);
197
- if (stats.averagePacketRate > 0) {
198
- frameRate = Math.min(FPS_CAP, Math.round(stats.averagePacketRate));
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
- catch (_a) {
203
- frameRate = undefined;
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: Object.assign({ height, quality: new mediabunny.Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }), hardwareAcceleration: 'prefer-hardware' }, (frameRate ? { frameRate } : {})),
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 null;
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.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
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 new CancelledError();
433
+ throw cancelledError();
227
434
  throw e;
228
435
  }
229
- const buffer = output.target.buffer;
230
- if (!buffer)
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
- // Built on a local, published to the field only once it is ready and still
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
- this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
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.log('[FFmpeg Log]', message);
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
- if (this.cancelRequested) {
465
+ try {
466
+ throwIfCancelled();
467
+ }
468
+ catch (e) {
263
469
  instance.terminate();
264
- throw new CancelledError();
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 CancelledError.
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
- let crf = '28';
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
- if (this.cancelRequested)
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
- const args = ['-i', inputFileName];
331
- args.push('-c:v', 'libx264', '-preset', 'superfast', '-crf', crf);
332
- if (scale) {
333
- args.push(...scale.split(' '));
334
- }
335
- args.push('-c:a', 'aac', '-b:a', audioBitrate);
336
- args.push(outputFileName);
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
- if (this.cancelRequested)
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
- if (this.cancelRequested)
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, ffmpeg, util, mediabunny);
554
+ })({}, capacitorExports, FFmpegWASM, FFmpegUtil, Mediabunny);
377
555
  //# sourceMappingURL=plugin.js.map