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/README.md +53 -33
- package/android/build.gradle +6 -4
- package/android/src/main/AndroidManifest.xml +8 -17
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressor.java +134 -4
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +385 -122
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionWorker.java +134 -0
- package/dist/docs.json +24 -24
- package/dist/esm/definitions.d.ts +52 -9
- 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 +21 -3
- package/dist/esm/web.js +194 -129
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +306 -128
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +307 -129
- 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/android/src/main/java/com/atomic/videocompressor/VideoCompressionService.java +0 -310
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,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,
|
|
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
|
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 } =
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
317
|
+
throw cancelledError();
|
|
218
318
|
throw e;
|
|
219
319
|
}
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
349
|
+
try {
|
|
350
|
+
throwIfCancelled();
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
254
353
|
instance.terminate();
|
|
255
|
-
throw
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|