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/dist/esm/web.js CHANGED
@@ -1,37 +1,76 @@
1
1
  import { WebPlugin } from '@capacitor/core';
2
2
  import { FFmpeg } from '@ffmpeg/ffmpeg';
3
3
  import { toBlobURL } from '@ffmpeg/util';
4
- import { ALL_FORMATS, BlobSource, BufferTarget, Conversion, ConversionCanceledError, Input, Mp4OutputFormat, Output, Quality, canEncodeVideo, } from 'mediabunny';
5
- import { CANCELLED } from './definitions';
4
+ import { ALL_FORMATS, BlobSource, BufferTarget, Conversion, ConversionCanceledError, Input, Mp4OutputFormat, Output, Quality, StreamTarget, canEncodeVideo, } from 'mediabunny';
5
+ import { CANCELLED, STORAGE_FULL, TOO_LARGE_FOR_WEB } from './definitions';
6
+ import { createOpfsOutput } from './opfs-output';
6
7
  /**
7
- * Rejection shape for a cancelled compress. `code` matches what `call.reject`
8
+ * Rejection shape for every coded failure. `code` matches what `call.reject`
8
9
  * puts on the error object on iOS/Android, so one `error.code === 'CANCELLED'`
9
- * check works on every platform — and on both web engines below.
10
+ * check works on every platform and on both web engines below.
10
11
  */
11
- class CancelledError extends Error {
12
- constructor() {
13
- super('Đã hủy nén');
14
- this.code = CANCELLED;
15
- this.name = 'CancelledError';
12
+ class CompressError extends Error {
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'CompressError';
16
17
  }
17
18
  }
19
+ const cancelledError = () => new CompressError(CANCELLED, 'Đã hủy nén');
20
+ const isQuotaError = (e) => {
21
+ const { name, cause } = (e !== null && e !== void 0 ? e : {});
22
+ return name === 'QuotaExceededError' || (!!cause && isQuotaError(cause));
23
+ };
24
+ /** ffmpeg.wasm copies the whole input into wasm memory (~2 GB ceiling). */
25
+ const MAX_FFMPEG_INPUT_BYTES = 200 * 1024 * 1024;
18
26
  /**
19
- * WebCodecs target per quality tier: cap height + a bitrate ceiling. The
20
- * quantizer (constant 26, CRF-like) drives quality where the encoder honours
21
- * it; H.264 on most browsers is bitrate-driven, so `bitrate` is the real knob.
22
- * fps is capped to 30 at call time (never upsampled). Audio is left to
23
- * mediabunny, which re-encodes to AAC for the MP4 container.
27
+ * Per quality tier. `shortSide` caps the shorter display side, so portrait and
28
+ * landscape get the same box as on iOS. WebCodecs: that + a bitrate ceiling; the quantizer
29
+ * (constant 26, CRF-like) drives quality where the encoder honours it, but
30
+ * H.264 on most browsers is bitrate-driven, so `bitrate` is the real knob.
31
+ * ffmpeg: same short side, `crf` and `audioBitrate`. fps is capped to 30 at call
32
+ * time (never upsampled). On WebCodecs audio is left to mediabunny, which
33
+ * re-encodes to AAC for the MP4 container.
24
34
  */
25
- const WEBCODECS_LADDER = {
26
- VERY_HIGH: { height: 1080, bitrate: 4000000 },
27
- HIGH: { height: 720, bitrate: 2000000 },
28
- MEDIUM: { height: 540, bitrate: 1200000 },
29
- LOW: { height: 480, bitrate: 1000000 },
30
- '360P': { height: 360, bitrate: 700000 },
31
- VERY_LOW: { height: 240, bitrate: 350000 },
35
+ const QUALITY_LADDER = {
36
+ VERY_HIGH: { shortSide: 1080, bitrate: 4000000, crf: 23, audioBitrate: '128k' },
37
+ HIGH: { shortSide: 720, bitrate: 2000000, crf: 28, audioBitrate: '128k' },
38
+ MEDIUM: { shortSide: 540, bitrate: 1200000, crf: 30, audioBitrate: '96k' },
39
+ LOW: { shortSide: 480, bitrate: 1000000, crf: 32, audioBitrate: '96k' },
40
+ '360P': { shortSide: 360, bitrate: 700000, crf: 28, audioBitrate: '64k' },
41
+ VERY_LOW: { shortSide: 240, bitrate: 350000, crf: 35, audioBitrate: '48k' },
32
42
  };
33
43
  const WEBCODECS_QUANTIZER = 26;
44
+ // Never upscales. Mediabunny derives the other side from the aspect ratio and rounds both up to even.
45
+ export const webCodecsSize = (displayWidth, displayHeight, shortSide) => {
46
+ const side = Math.min(shortSide, displayWidth, displayHeight);
47
+ return displayHeight > displayWidth ? { width: side } : { height: side };
48
+ };
49
+ // iw/ih are post-autorotate here: ffmpeg inserts the transpose ahead of -vf.
50
+ // Never upscales; trunc keeps an odd source side even for libx264.
51
+ export 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)'`;
34
52
  const FPS_CAP = 30;
53
+ const MIN_VIDEO_BITRATE = 200000;
54
+ // A lean source re-encoded at the tier bitrate comes out larger than it went in.
55
+ // The floor stops a broken source estimate from producing an unwatchable file.
56
+ export const capBitrate = (target, sourceBitrate, floor, headroom = 1) => sourceBitrate && sourceBitrate > 0 ? Math.min(target, Math.max(floor, Math.round(sourceBitrate * headroom))) : target;
57
+ // H.264 needs about 1.5x the bitrate of these codecs for the same picture.
58
+ export const codecHeadroom = (codec) => codec === 'hevc' || codec === 'vp9' || codec === 'av1' ? 1.5 : 1;
59
+ // Cap fps at 30, but never upsample. A failed probe leaves the source rate and bitrate untouched.
60
+ const probeVideoStats = async (track) => {
61
+ try {
62
+ const stats = await (track === null || track === void 0 ? void 0 : track.computePacketStats(100));
63
+ if (!stats)
64
+ return {};
65
+ return {
66
+ frameRate: stats.averagePacketRate > 0 ? Math.min(FPS_CAP, Math.round(stats.averagePacketRate)) : undefined,
67
+ bitrate: stats.averageBitrate,
68
+ };
69
+ }
70
+ catch (_a) {
71
+ return {};
72
+ }
73
+ };
35
74
  const CORE_BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
36
75
  /**
37
76
  * Cancelling means terminating the worker, which drops the loaded core with
@@ -64,34 +103,42 @@ export class NativeVideoCompressorWeb extends WebPlugin {
64
103
  this.isLoaded = false;
65
104
  /** The mediabunny conversion running now, so `cancel()` can abort it. */
66
105
  this.activeConversion = null;
67
- /** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
68
- this.currentJobId = null;
106
+ /** The job `compressVideo` is running right now; a fresh object per call, so two calls never look alike. */
107
+ this.currentJob = null;
69
108
  this.cancelRequested = false;
109
+ /** Kept OPFS outputs by jobId, deleted by `finishUpload`. */
110
+ this.keptOutputs = new Map();
70
111
  }
71
112
  async initialize() {
72
- // WebCodecs (the primary path) needs no core download, so there is nothing
73
- // to warm. Eagerly loading the ffmpeg core here would pull ~30 MB for a
74
- // path most compresses never take. The fallback lazy-loads its core on
75
- // first use. Kept as a resolving no-op so callers that await it still pass.
113
+ // No-op: preloading the ~30 MB ffmpeg core would cost most compresses a download they never use.
76
114
  return { success: true, message: 'ready' };
77
115
  }
78
- async finishUpload() {
79
- // Nothing holds a process alive on the web — the tab either lives or the
80
- // whole compress is gone with it. Resolving no-op so the one call site
81
- // can fire unconditionally instead of branching on platform.
82
- return { released: false };
116
+ async finishUpload({ jobId }) {
117
+ const output = this.keptOutputs.get(jobId);
118
+ if (!output)
119
+ return { released: false };
120
+ this.keptOutputs.delete(jobId);
121
+ await output.discard();
122
+ return { released: true };
83
123
  }
84
124
  async compressVideo(options) {
85
125
  var _a, _b;
86
- this.currentJobId = (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default';
126
+ const job = { id: (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default' };
127
+ if (this.currentJob)
128
+ this.stopEngines();
129
+ this.currentJob = job;
87
130
  this.cancelRequested = false;
131
+ // A job the caller gave up on (e.g. a stall timeout) runs on until its next
132
+ // await returns. Once another job has started, the shared flags above speak
133
+ // for that job, not this one, so a superseded job counts as cancelled.
134
+ const isSuperseded = () => this.currentJob !== job;
88
135
  // Checked at every stage boundary, not just around the encode: cancelling
89
136
  // an engine only reaches work already handed to it, so a cancel during the
90
137
  // source fetch or before init has nothing to interrupt and would otherwise
91
138
  // let the compress carry on as if nothing had happened.
92
139
  const throwIfCancelled = () => {
93
- if (this.cancelRequested)
94
- throw new CancelledError();
140
+ if (this.cancelRequested || isSuperseded())
141
+ throw cancelledError();
95
142
  };
96
143
  try {
97
144
  throwIfCancelled();
@@ -100,18 +147,22 @@ export class NativeVideoCompressorWeb extends WebPlugin {
100
147
  const quality = (_b = options.quality) !== null && _b !== void 0 ? _b : 'MEDIUM';
101
148
  if (await this.canUseWebCodecs(quality)) {
102
149
  try {
103
- const result = await this.compressWithWebCodecs(blob, quality, throwIfCancelled);
150
+ const result = await this.compressWithWebCodecs(blob, quality, job.id, throwIfCancelled);
104
151
  if (result)
105
152
  return result;
106
- // null → conversion invalid / tracks discarded → fall back to ffmpeg.
107
153
  }
108
154
  catch (e) {
109
155
  if ((e === null || e === void 0 ? void 0 : e.code) === CANCELLED)
110
156
  throw e; // user cancel: never fall back
157
+ // Not to ffmpeg either: it needs even more room, and its size cap would misreport this as TOO_LARGE_FOR_WEB.
158
+ if (isQuotaError(e))
159
+ throw new CompressError(STORAGE_FULL, 'Không đủ dung lượng lưu trữ để nén video');
111
160
  console.warn('[compress] WebCodecs failed, falling back to ffmpeg:', e);
112
- throwIfCancelled();
113
161
  }
114
162
  }
163
+ throwIfCancelled();
164
+ if (blob.size > MAX_FFMPEG_INPUT_BYTES)
165
+ throw new CompressError(TOO_LARGE_FOR_WEB, 'Video quá lớn để nén trên trình duyệt này');
115
166
  return await this.compressWithFfmpeg(blob, quality, throwIfCancelled);
116
167
  }
117
168
  catch (error) {
@@ -124,9 +175,11 @@ export class NativeVideoCompressorWeb extends WebPlugin {
124
175
  throw error;
125
176
  }
126
177
  finally {
127
- this.currentJobId = null;
128
- this.cancelRequested = false;
129
- this.activeConversion = null;
178
+ if (!isSuperseded()) {
179
+ this.currentJob = null;
180
+ this.cancelRequested = false;
181
+ this.activeConversion = null;
182
+ }
130
183
  }
131
184
  }
132
185
  /**
@@ -134,30 +187,33 @@ export class NativeVideoCompressorWeb extends WebPlugin {
134
187
  * so acting on both is safe.
135
188
  *
136
189
  * WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
137
- * with `ConversionCanceledError`, surfaced as a `CancelledError` below.
190
+ * with `ConversionCanceledError`, surfaced as a CANCELLED `CompressError` below.
138
191
  * ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
139
192
  * message poll while encoding, so `worker.terminate()` is the only way to
140
193
  * stop it — its cost is the loaded core, which is why the core URLs are
141
194
  * cached above.
142
195
  */
143
196
  async cancel(options) {
144
- var _a;
145
- if (!this.currentJobId)
197
+ if (!this.currentJob)
146
198
  return { cancelled: false };
147
- if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJobId)
199
+ if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJob.id)
148
200
  return { cancelled: false };
149
201
  // Set even when neither engine exists yet: the job may still be fetching
150
202
  // the source or inside loadFFmpeg, which read this flag at their boundaries.
151
203
  this.cancelRequested = true;
204
+ this.stopEngines();
205
+ return { cancelled: true };
206
+ }
207
+ stopEngines() {
208
+ var _a;
152
209
  const conversion = this.activeConversion;
153
210
  this.activeConversion = null;
154
- // Fire-and-forget: the CancelledError surfaces from the execute() rejection,
211
+ // Fire-and-forget: the CANCELLED error surfaces from the execute() rejection,
155
212
  // not from here; awaiting would only make cancel() slower.
156
213
  conversion === null || conversion === void 0 ? void 0 : conversion.cancel().catch(() => undefined);
157
214
  (_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
158
215
  this.ffmpeg = null;
159
216
  this.isLoaded = false;
160
- return { cancelled: true };
161
217
  }
162
218
  async fetchSourceBlob(sourcePath) {
163
219
  try {
@@ -171,8 +227,9 @@ export class NativeVideoCompressorWeb extends WebPlugin {
171
227
  }
172
228
  async canUseWebCodecs(quality) {
173
229
  try {
174
- const { bitrate } = WEBCODECS_LADDER[quality];
230
+ const { bitrate } = QUALITY_LADDER[quality];
175
231
  return await canEncodeVideo('avc', {
232
+ hardwareAcceleration: 'no-preference',
176
233
  quality: new Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }),
177
234
  });
178
235
  }
@@ -180,71 +237,104 @@ export class NativeVideoCompressorWeb extends WebPlugin {
180
237
  return false;
181
238
  }
182
239
  }
183
- async compressWithWebCodecs(blob, quality, throwIfCancelled) {
184
- const { height, bitrate } = WEBCODECS_LADDER[quality];
185
- const input = new Input({ formats: ALL_FORMATS, source: new BlobSource(blob) });
186
- const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
187
- // Cap fps at 30, but never upsample: probe the source and take the min.
188
- // A failed probe just omits frameRate, leaving the source rate untouched.
189
- let frameRate;
240
+ async compressWithWebCodecs(blob, quality, jobId, throwIfCancelled) {
241
+ const opfs = await createOpfsOutput();
242
+ console.debug(`[compress] engine=webcodecs target=${opfs ? 'opfs' : 'buffer'}`);
243
+ let output;
244
+ let kept = false;
190
245
  try {
191
- const track = await input.getPrimaryVideoTrack();
192
- if (track) {
193
- const stats = await track.computePacketStats(100);
194
- if (stats.averagePacketRate > 0) {
195
- frameRate = Math.min(FPS_CAP, Math.round(stats.averagePacketRate));
196
- }
246
+ const input = new Input({ formats: ALL_FORMATS, source: new BlobSource(blob) });
247
+ output = new Output({
248
+ format: new Mp4OutputFormat(),
249
+ target: opfs ? new StreamTarget(opfs.writable, { chunked: true }) : new BufferTarget(),
250
+ });
251
+ if (!(await this.runConversion(input, output, QUALITY_LADDER[quality], throwIfCancelled)))
252
+ return null;
253
+ let outBlob;
254
+ if (opfs) {
255
+ outBlob = await opfs.read();
256
+ }
257
+ else {
258
+ const buffer = output.target.buffer;
259
+ if (!buffer)
260
+ throw new Error('WebCodecs produced no output');
261
+ outBlob = new Blob([buffer], { type: 'video/mp4' });
197
262
  }
263
+ kept = true;
264
+ if (opfs)
265
+ this.keptOutputs.set(jobId, opfs);
266
+ return { success: true, destPath: URL.createObjectURL(outBlob) };
198
267
  }
199
- catch (_a) {
200
- frameRate = undefined;
268
+ finally {
269
+ if (!kept) {
270
+ // Closes the writable first, which otherwise keeps the entry locked against removal.
271
+ await (output === null || output === void 0 ? void 0 : output.cancel().catch(() => undefined));
272
+ await (opfs === null || opfs === void 0 ? void 0 : opfs.discard());
273
+ }
201
274
  }
275
+ }
276
+ /** False when mediabunny can't convert this input. */
277
+ async runConversion(input, output, { shortSide, bitrate }, throwIfCancelled) {
278
+ const track = await input.getPrimaryVideoTrack();
279
+ const { frameRate, bitrate: sourceBitrate } = await probeVideoStats(track);
280
+ const size = track ? webCodecsSize(await track.getDisplayWidth(), await track.getDisplayHeight(), shortSide) : {};
202
281
  throwIfCancelled();
203
282
  const conversion = await Conversion.init({
204
283
  input,
205
284
  output,
206
- video: Object.assign({ height, quality: new Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }), hardwareAcceleration: 'prefer-hardware' }, (frameRate ? { frameRate } : {})),
285
+ video: {
286
+ ...size,
287
+ quality: new Quality({
288
+ quantizer: WEBCODECS_QUANTIZER,
289
+ bitrate: capBitrate(bitrate, sourceBitrate, MIN_VIDEO_BITRATE, codecHeadroom(track === null || track === void 0 ? void 0 : track.codec)),
290
+ }),
291
+ // Not 'prefer-hardware': machines without a hardware H.264 encoder
292
+ // (Linux, VMs, headless) then fail mid-encode and drop to ffmpeg.wasm.
293
+ // The browser still picks the hardware encoder when it has one, and the
294
+ // canEncodeVideo probe asks the same question, so probe and encode agree.
295
+ hardwareAcceleration: 'no-preference',
296
+ ...(frameRate ? { frameRate } : {}),
297
+ },
298
+ // The default copies the input's raw udta metadata tags, GPS location included.
299
+ tags: {},
207
300
  });
208
301
  if (!conversion.isValid) {
209
302
  console.warn('[compress] WebCodecs conversion invalid:', conversion.discardedTracks.map((t) => t.reason));
210
- return null;
303
+ return false;
211
304
  }
305
+ throwIfCancelled();
212
306
  this.activeConversion = conversion;
213
307
  this.notifyListeners('onProgress', { status: 'started', percent: 0 });
214
308
  conversion.onProgress = (progress) => {
215
- this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
309
+ if (this.activeConversion === conversion)
310
+ this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
216
311
  };
217
- throwIfCancelled();
218
312
  try {
219
313
  await conversion.execute();
220
314
  }
221
315
  catch (e) {
222
316
  if (this.cancelRequested || e instanceof ConversionCanceledError)
223
- throw new CancelledError();
317
+ throw cancelledError();
224
318
  throw e;
225
319
  }
226
- const buffer = output.target.buffer;
227
- if (!buffer)
228
- throw new Error('WebCodecs produced no output');
229
- const outBlob = new Blob([buffer], { type: 'video/mp4' });
230
- return { success: true, destPath: URL.createObjectURL(outBlob) };
320
+ throwIfCancelled();
321
+ return true;
231
322
  }
232
- async loadFFmpeg() {
323
+ async loadFFmpeg(throwIfCancelled) {
233
324
  if (this.isLoaded && this.ffmpeg)
234
325
  return;
326
+ throwIfCancelled();
235
327
  this.notifyListeners('onProgress', { status: 'loading_core', percent: 0 });
236
- // Built on a local, published to the field only once it is ready and still
237
- // wanted. Assigning `this.ffmpeg` up front and setting `isLoaded` after the
238
- // await left a window where a cancel could null the instance and the load,
239
- // finishing a moment later, would set `isLoaded = true` on nothing at all —
240
- // after which every compressVideo threw 'FFmpeg failed to load' for the
241
- // rest of the page's life.
328
+ // Published only once loaded and still wanted, so a cancel mid-load can't leave `isLoaded` set on a dropped instance.
242
329
  const instance = new FFmpeg();
243
330
  instance.on('progress', ({ progress }) => {
244
- this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
331
+ // Once published the instance serves every later job; before that, or
332
+ // after cancel() dropped it, its events belong to no job.
333
+ if (this.ffmpeg === instance)
334
+ this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
245
335
  });
246
336
  instance.on('log', ({ message }) => {
247
- console.log('[FFmpeg Log]', message);
337
+ console.debug('[FFmpeg Log]', message);
248
338
  });
249
339
  try {
250
340
  await instance.load(await loadCoreURLs());
@@ -256,16 +346,19 @@ export class NativeVideoCompressorWeb extends WebPlugin {
256
346
  }
257
347
  // Cancelled while this was loading. Terminate what we just built rather
258
348
  // than publish it — the caller asked for none of it.
259
- if (this.cancelRequested) {
349
+ try {
350
+ throwIfCancelled();
351
+ }
352
+ catch (e) {
260
353
  instance.terminate();
261
- throw new CancelledError();
354
+ throw e;
262
355
  }
263
356
  this.ffmpeg = instance;
264
357
  this.isLoaded = true;
265
358
  }
266
359
  async compressWithFfmpeg(blob, quality, throwIfCancelled) {
267
360
  if (!this.isLoaded) {
268
- await this.loadFFmpeg();
361
+ await this.loadFFmpeg(throwIfCancelled);
269
362
  }
270
363
  throwIfCancelled();
271
364
  if (!this.ffmpeg) {
@@ -273,72 +366,39 @@ export class NativeVideoCompressorWeb extends WebPlugin {
273
366
  }
274
367
  // Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after any
275
368
  // of the awaits below can hand back null on a reference the compiler
276
- // narrowed here — surfacing a cancel as a TypeError, not a CancelledError.
369
+ // narrowed here — surfacing a cancel as a TypeError, not a CANCELLED error.
277
370
  const ffmpeg = this.ffmpeg;
371
+ console.debug('[compress] engine=ffmpeg');
278
372
  this.notifyListeners('onProgress', { status: 'started', percent: 0 });
279
373
  const inputFileName = 'input.mp4';
280
374
  const outputFileName = 'output.mp4';
281
- let crf = '28';
282
- let scale = '';
283
- let audioBitrate = '128k';
284
- switch (quality) {
285
- case 'VERY_HIGH':
286
- crf = '23';
287
- scale = '-vf scale=-2:1080';
288
- audioBitrate = '128k';
289
- break;
290
- case 'HIGH':
291
- crf = '28';
292
- scale = '-vf scale=-2:720';
293
- audioBitrate = '128k';
294
- break;
295
- case 'MEDIUM':
296
- crf = '30';
297
- scale = '-vf scale=-2:540';
298
- audioBitrate = '96k';
299
- break;
300
- case 'LOW':
301
- crf = '32';
302
- scale = '-vf scale=-2:480';
303
- audioBitrate = '96k';
304
- break;
305
- case '360P':
306
- crf = '28';
307
- scale = '-vf scale=-2:360';
308
- audioBitrate = '64k';
309
- break;
310
- case 'VERY_LOW':
311
- crf = '35';
312
- scale = '-vf scale=-2:240';
313
- audioBitrate = '48k';
314
- break;
315
- }
375
+ const { shortSide, crf, audioBitrate } = QUALITY_LADDER[quality];
316
376
  const inputData = new Uint8Array(await blob.arrayBuffer());
317
377
  try {
318
378
  await ffmpeg.writeFile(inputFileName, inputData);
319
379
  }
320
380
  catch (err) {
321
- if (this.cancelRequested)
322
- throw new CancelledError();
381
+ throwIfCancelled();
323
382
  console.error('[FFmpeg] Error writing to FS:', err);
324
383
  throw new Error('Could not write to FFmpeg FS');
325
384
  }
326
385
  throwIfCancelled();
327
- const args = ['-i', inputFileName];
328
- args.push('-c:v', 'libx264', '-preset', 'superfast', '-crf', crf);
329
- if (scale) {
330
- args.push(...scale.split(' '));
331
- }
332
- args.push('-c:a', 'aac', '-b:a', audioBitrate);
333
- args.push(outputFileName);
386
+ // ffmpeg copies global metadata by default, including the GPS location (©xyz / loci).
387
+ // prettier-ignore
388
+ const args = [
389
+ '-i', inputFileName, '-map_metadata', '-1',
390
+ '-c:v', 'libx264', '-preset', 'superfast', '-crf', String(crf),
391
+ '-vf', ffmpegScaleFilter(shortSide),
392
+ '-c:a', 'aac', '-b:a', audioBitrate,
393
+ outputFileName,
394
+ ];
334
395
  try {
335
396
  await ffmpeg.exec(args);
336
397
  }
337
398
  catch (err) {
338
399
  // terminate() rejects every in-flight call, so a cancel arrives here
339
400
  // looking like a generic failure unless we name it.
340
- if (this.cancelRequested)
341
- throw new CancelledError();
401
+ throwIfCancelled();
342
402
  console.error('[FFmpeg] Error executing command:', err);
343
403
  throw new Error('FFmpeg execution failed');
344
404
  }
@@ -348,8 +408,7 @@ export class NativeVideoCompressorWeb extends WebPlugin {
348
408
  data = await ffmpeg.readFile(outputFileName);
349
409
  }
350
410
  catch (err) {
351
- if (this.cancelRequested)
352
- throw new CancelledError();
411
+ throwIfCancelled();
353
412
  console.error('[FFmpeg] Error reading output file:', err);
354
413
  throw new Error('Could not read FFmpeg output');
355
414
  }