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/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,28 +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
  }
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 };
123
+ }
78
124
  async compressVideo(options) {
79
125
  var _a, _b;
80
- 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;
81
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;
82
135
  // Checked at every stage boundary, not just around the encode: cancelling
83
136
  // an engine only reaches work already handed to it, so a cancel during the
84
137
  // source fetch or before init has nothing to interrupt and would otherwise
85
138
  // let the compress carry on as if nothing had happened.
86
139
  const throwIfCancelled = () => {
87
- if (this.cancelRequested)
88
- throw new CancelledError();
140
+ if (this.cancelRequested || isSuperseded())
141
+ throw cancelledError();
89
142
  };
90
143
  try {
91
144
  throwIfCancelled();
@@ -94,18 +147,22 @@ export class NativeVideoCompressorWeb extends WebPlugin {
94
147
  const quality = (_b = options.quality) !== null && _b !== void 0 ? _b : 'MEDIUM';
95
148
  if (await this.canUseWebCodecs(quality)) {
96
149
  try {
97
- const result = await this.compressWithWebCodecs(blob, quality, throwIfCancelled);
150
+ const result = await this.compressWithWebCodecs(blob, quality, job.id, throwIfCancelled);
98
151
  if (result)
99
152
  return result;
100
- // null → conversion invalid / tracks discarded → fall back to ffmpeg.
101
153
  }
102
154
  catch (e) {
103
155
  if ((e === null || e === void 0 ? void 0 : e.code) === CANCELLED)
104
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');
105
160
  console.warn('[compress] WebCodecs failed, falling back to ffmpeg:', e);
106
- throwIfCancelled();
107
161
  }
108
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');
109
166
  return await this.compressWithFfmpeg(blob, quality, throwIfCancelled);
110
167
  }
111
168
  catch (error) {
@@ -118,9 +175,11 @@ export class NativeVideoCompressorWeb extends WebPlugin {
118
175
  throw error;
119
176
  }
120
177
  finally {
121
- this.currentJobId = null;
122
- this.cancelRequested = false;
123
- this.activeConversion = null;
178
+ if (!isSuperseded()) {
179
+ this.currentJob = null;
180
+ this.cancelRequested = false;
181
+ this.activeConversion = null;
182
+ }
124
183
  }
125
184
  }
126
185
  /**
@@ -128,30 +187,33 @@ export class NativeVideoCompressorWeb extends WebPlugin {
128
187
  * so acting on both is safe.
129
188
  *
130
189
  * WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
131
- * with `ConversionCanceledError`, surfaced as a `CancelledError` below.
190
+ * with `ConversionCanceledError`, surfaced as a CANCELLED `CompressError` below.
132
191
  * ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
133
192
  * message poll while encoding, so `worker.terminate()` is the only way to
134
193
  * stop it — its cost is the loaded core, which is why the core URLs are
135
194
  * cached above.
136
195
  */
137
196
  async cancel(options) {
138
- var _a;
139
- if (!this.currentJobId)
197
+ if (!this.currentJob)
140
198
  return { cancelled: false };
141
- 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)
142
200
  return { cancelled: false };
143
201
  // Set even when neither engine exists yet: the job may still be fetching
144
202
  // the source or inside loadFFmpeg, which read this flag at their boundaries.
145
203
  this.cancelRequested = true;
204
+ this.stopEngines();
205
+ return { cancelled: true };
206
+ }
207
+ stopEngines() {
208
+ var _a;
146
209
  const conversion = this.activeConversion;
147
210
  this.activeConversion = null;
148
- // Fire-and-forget: the CancelledError surfaces from the execute() rejection,
211
+ // Fire-and-forget: the CANCELLED error surfaces from the execute() rejection,
149
212
  // not from here; awaiting would only make cancel() slower.
150
213
  conversion === null || conversion === void 0 ? void 0 : conversion.cancel().catch(() => undefined);
151
214
  (_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
152
215
  this.ffmpeg = null;
153
216
  this.isLoaded = false;
154
- return { cancelled: true };
155
217
  }
156
218
  async fetchSourceBlob(sourcePath) {
157
219
  try {
@@ -165,8 +227,9 @@ export class NativeVideoCompressorWeb extends WebPlugin {
165
227
  }
166
228
  async canUseWebCodecs(quality) {
167
229
  try {
168
- const { bitrate } = WEBCODECS_LADDER[quality];
230
+ const { bitrate } = QUALITY_LADDER[quality];
169
231
  return await canEncodeVideo('avc', {
232
+ hardwareAcceleration: 'no-preference',
170
233
  quality: new Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }),
171
234
  });
172
235
  }
@@ -174,71 +237,104 @@ export class NativeVideoCompressorWeb extends WebPlugin {
174
237
  return false;
175
238
  }
176
239
  }
177
- async compressWithWebCodecs(blob, quality, throwIfCancelled) {
178
- const { height, bitrate } = WEBCODECS_LADDER[quality];
179
- const input = new Input({ formats: ALL_FORMATS, source: new BlobSource(blob) });
180
- const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
181
- // Cap fps at 30, but never upsample: probe the source and take the min.
182
- // A failed probe just omits frameRate, leaving the source rate untouched.
183
- 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;
184
245
  try {
185
- const track = await input.getPrimaryVideoTrack();
186
- if (track) {
187
- const stats = await track.computePacketStats(100);
188
- if (stats.averagePacketRate > 0) {
189
- frameRate = Math.min(FPS_CAP, Math.round(stats.averagePacketRate));
190
- }
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();
191
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' });
262
+ }
263
+ kept = true;
264
+ if (opfs)
265
+ this.keptOutputs.set(jobId, opfs);
266
+ return { success: true, destPath: URL.createObjectURL(outBlob) };
192
267
  }
193
- catch (_a) {
194
- 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
+ }
195
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) : {};
196
281
  throwIfCancelled();
197
282
  const conversion = await Conversion.init({
198
283
  input,
199
284
  output,
200
- 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: {},
201
300
  });
202
301
  if (!conversion.isValid) {
203
302
  console.warn('[compress] WebCodecs conversion invalid:', conversion.discardedTracks.map((t) => t.reason));
204
- return null;
303
+ return false;
205
304
  }
305
+ throwIfCancelled();
206
306
  this.activeConversion = conversion;
207
307
  this.notifyListeners('onProgress', { status: 'started', percent: 0 });
208
308
  conversion.onProgress = (progress) => {
209
- 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) });
210
311
  };
211
- throwIfCancelled();
212
312
  try {
213
313
  await conversion.execute();
214
314
  }
215
315
  catch (e) {
216
316
  if (this.cancelRequested || e instanceof ConversionCanceledError)
217
- throw new CancelledError();
317
+ throw cancelledError();
218
318
  throw e;
219
319
  }
220
- const buffer = output.target.buffer;
221
- if (!buffer)
222
- throw new Error('WebCodecs produced no output');
223
- const outBlob = new Blob([buffer], { type: 'video/mp4' });
224
- return { success: true, destPath: URL.createObjectURL(outBlob) };
320
+ throwIfCancelled();
321
+ return true;
225
322
  }
226
- async loadFFmpeg() {
323
+ async loadFFmpeg(throwIfCancelled) {
227
324
  if (this.isLoaded && this.ffmpeg)
228
325
  return;
326
+ throwIfCancelled();
229
327
  this.notifyListeners('onProgress', { status: 'loading_core', percent: 0 });
230
- // Built on a local, published to the field only once it is ready and still
231
- // wanted. Assigning `this.ffmpeg` up front and setting `isLoaded` after the
232
- // await left a window where a cancel could null the instance and the load,
233
- // finishing a moment later, would set `isLoaded = true` on nothing at all —
234
- // after which every compressVideo threw 'FFmpeg failed to load' for the
235
- // 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.
236
329
  const instance = new FFmpeg();
237
330
  instance.on('progress', ({ progress }) => {
238
- 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) });
239
335
  });
240
336
  instance.on('log', ({ message }) => {
241
- console.log('[FFmpeg Log]', message);
337
+ console.debug('[FFmpeg Log]', message);
242
338
  });
243
339
  try {
244
340
  await instance.load(await loadCoreURLs());
@@ -250,16 +346,19 @@ export class NativeVideoCompressorWeb extends WebPlugin {
250
346
  }
251
347
  // Cancelled while this was loading. Terminate what we just built rather
252
348
  // than publish it — the caller asked for none of it.
253
- if (this.cancelRequested) {
349
+ try {
350
+ throwIfCancelled();
351
+ }
352
+ catch (e) {
254
353
  instance.terminate();
255
- throw new CancelledError();
354
+ throw e;
256
355
  }
257
356
  this.ffmpeg = instance;
258
357
  this.isLoaded = true;
259
358
  }
260
359
  async compressWithFfmpeg(blob, quality, throwIfCancelled) {
261
360
  if (!this.isLoaded) {
262
- await this.loadFFmpeg();
361
+ await this.loadFFmpeg(throwIfCancelled);
263
362
  }
264
363
  throwIfCancelled();
265
364
  if (!this.ffmpeg) {
@@ -267,72 +366,39 @@ export class NativeVideoCompressorWeb extends WebPlugin {
267
366
  }
268
367
  // Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after any
269
368
  // of the awaits below can hand back null on a reference the compiler
270
- // narrowed here — surfacing a cancel as a TypeError, not a CancelledError.
369
+ // narrowed here — surfacing a cancel as a TypeError, not a CANCELLED error.
271
370
  const ffmpeg = this.ffmpeg;
371
+ console.debug('[compress] engine=ffmpeg');
272
372
  this.notifyListeners('onProgress', { status: 'started', percent: 0 });
273
373
  const inputFileName = 'input.mp4';
274
374
  const outputFileName = 'output.mp4';
275
- let crf = '28';
276
- let scale = '';
277
- let audioBitrate = '128k';
278
- switch (quality) {
279
- case 'VERY_HIGH':
280
- crf = '23';
281
- scale = '-vf scale=-2:1080';
282
- audioBitrate = '128k';
283
- break;
284
- case 'HIGH':
285
- crf = '28';
286
- scale = '-vf scale=-2:720';
287
- audioBitrate = '128k';
288
- break;
289
- case 'MEDIUM':
290
- crf = '30';
291
- scale = '-vf scale=-2:540';
292
- audioBitrate = '96k';
293
- break;
294
- case 'LOW':
295
- crf = '32';
296
- scale = '-vf scale=-2:480';
297
- audioBitrate = '96k';
298
- break;
299
- case '360P':
300
- crf = '28';
301
- scale = '-vf scale=-2:360';
302
- audioBitrate = '64k';
303
- break;
304
- case 'VERY_LOW':
305
- crf = '35';
306
- scale = '-vf scale=-2:240';
307
- audioBitrate = '48k';
308
- break;
309
- }
375
+ const { shortSide, crf, audioBitrate } = QUALITY_LADDER[quality];
310
376
  const inputData = new Uint8Array(await blob.arrayBuffer());
311
377
  try {
312
378
  await ffmpeg.writeFile(inputFileName, inputData);
313
379
  }
314
380
  catch (err) {
315
- if (this.cancelRequested)
316
- throw new CancelledError();
381
+ throwIfCancelled();
317
382
  console.error('[FFmpeg] Error writing to FS:', err);
318
383
  throw new Error('Could not write to FFmpeg FS');
319
384
  }
320
385
  throwIfCancelled();
321
- const args = ['-i', inputFileName];
322
- args.push('-c:v', 'libx264', '-preset', 'superfast', '-crf', crf);
323
- if (scale) {
324
- args.push(...scale.split(' '));
325
- }
326
- args.push('-c:a', 'aac', '-b:a', audioBitrate);
327
- 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
+ ];
328
395
  try {
329
396
  await ffmpeg.exec(args);
330
397
  }
331
398
  catch (err) {
332
399
  // terminate() rejects every in-flight call, so a cancel arrives here
333
400
  // looking like a generic failure unless we name it.
334
- if (this.cancelRequested)
335
- throw new CancelledError();
401
+ throwIfCancelled();
336
402
  console.error('[FFmpeg] Error executing command:', err);
337
403
  throw new Error('FFmpeg execution failed');
338
404
  }
@@ -342,8 +408,7 @@ export class NativeVideoCompressorWeb extends WebPlugin {
342
408
  data = await ffmpeg.readFile(outputFileName);
343
409
  }
344
410
  catch (err) {
345
- if (this.cancelRequested)
346
- throw new CancelledError();
411
+ throwIfCancelled();
347
412
  console.error('[FFmpeg] Error reading output file:', err);
348
413
  throw new Error('Could not read FFmpeg output');
349
414
  }