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