capacitor-native-video-compressor 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressor.java +134 -4
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +184 -35
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionWorker.java +15 -1
- package/dist/docs.json +1 -1
- package/dist/esm/definitions.d.ts +17 -0
- package/dist/esm/definitions.js +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/opfs-output.d.ts +7 -0
- package/dist/esm/opfs-output.js +94 -0
- package/dist/esm/opfs-output.js.map +1 -0
- package/dist/esm/web.d.ts +19 -4
- package/dist/esm/web.js +193 -134
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +305 -133
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +306 -134
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressor.swift +34 -0
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressorPlugin.swift +118 -79
- package/ios/Tests/NativeVideoCompressorPluginTests/NativeVideoCompressorTests.swift +44 -0
- package/package.json +9 -4
package/dist/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
|
|
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
|
|
10
|
+
* check works on every platform and on both web engines below.
|
|
10
11
|
*/
|
|
11
|
-
class
|
|
12
|
-
constructor() {
|
|
13
|
-
super(
|
|
14
|
-
this.code =
|
|
15
|
-
this.name = '
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
26
|
-
VERY_HIGH: {
|
|
27
|
-
HIGH: {
|
|
28
|
-
MEDIUM: {
|
|
29
|
-
LOW: {
|
|
30
|
-
'360P': {
|
|
31
|
-
VERY_LOW: {
|
|
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,
|
|
68
|
-
this.
|
|
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
|
-
//
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 } =
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
317
|
+
throw cancelledError();
|
|
224
318
|
throw e;
|
|
225
319
|
}
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
349
|
+
try {
|
|
350
|
+
throwIfCancelled();
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
260
353
|
instance.terminate();
|
|
261
|
-
throw
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|