capacitor-native-video-compressor 1.0.5 → 1.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.d.ts +19 -13
- package/dist/esm/web.js +264 -186
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +263 -185
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +264 -187
- package/dist/plugin.js.map +1 -1
- package/package.json +3 -2
package/dist/esm/web.d.ts
CHANGED
|
@@ -3,25 +3,31 @@ import type { NativeVideoCompressorPlugin, CompressOptions, CompressResult, Canc
|
|
|
3
3
|
export declare class NativeVideoCompressorWeb extends WebPlugin implements NativeVideoCompressorPlugin {
|
|
4
4
|
private ffmpeg;
|
|
5
5
|
private isLoaded;
|
|
6
|
+
/** The mediabunny conversion running now, so `cancel()` can abort it. */
|
|
7
|
+
private activeConversion;
|
|
6
8
|
/** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
|
|
7
9
|
private currentJobId;
|
|
8
10
|
private cancelRequested;
|
|
9
|
-
private loadFFmpeg;
|
|
10
|
-
/**
|
|
11
|
-
* Kill the worker outright — there is no gentler option.
|
|
12
|
-
*
|
|
13
|
-
* `ffmpeg.exec()` runs *synchronously* inside the worker (see
|
|
14
|
-
* @ffmpeg/ffmpeg's worker.js: `ffmpeg.exec(...args)` is not awaited), so the
|
|
15
|
-
* worker never reaches a message poll while encoding. The AbortSignal that
|
|
16
|
-
* `exec()` accepts only rejects the main-thread promise; the encode keeps
|
|
17
|
-
* burning CPU behind it. `worker.terminate()` is what actually stops it.
|
|
18
|
-
*
|
|
19
|
-
* The cost is the loaded core, which is why the core URLs are cached above.
|
|
20
|
-
*/
|
|
21
|
-
cancel(options?: CancelOptions): Promise<CancelResult>;
|
|
22
11
|
initialize(): Promise<{
|
|
23
12
|
success: boolean;
|
|
24
13
|
message?: string;
|
|
25
14
|
}>;
|
|
26
15
|
compressVideo(options: CompressOptions): Promise<CompressResult>;
|
|
16
|
+
/**
|
|
17
|
+
* Kill whichever engine is mid-compress. Only one is ever active at a time,
|
|
18
|
+
* so acting on both is safe.
|
|
19
|
+
*
|
|
20
|
+
* WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
|
|
21
|
+
* with `ConversionCanceledError`, surfaced as a `CancelledError` below.
|
|
22
|
+
* ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
|
|
23
|
+
* message poll while encoding, so `worker.terminate()` is the only way to
|
|
24
|
+
* stop it — its cost is the loaded core, which is why the core URLs are
|
|
25
|
+
* cached above.
|
|
26
|
+
*/
|
|
27
|
+
cancel(options?: CancelOptions): Promise<CancelResult>;
|
|
28
|
+
private fetchSourceBlob;
|
|
29
|
+
private canUseWebCodecs;
|
|
30
|
+
private compressWithWebCodecs;
|
|
31
|
+
private loadFFmpeg;
|
|
32
|
+
private compressWithFfmpeg;
|
|
27
33
|
}
|
package/dist/esm/web.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { WebPlugin } from '@capacitor/core';
|
|
2
2
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
|
3
|
-
import {
|
|
3
|
+
import { toBlobURL } from '@ffmpeg/util';
|
|
4
|
+
import { ALL_FORMATS, BlobSource, BufferTarget, Conversion, ConversionCanceledError, Input, Mp4OutputFormat, Output, Quality, canEncodeVideo, } from 'mediabunny';
|
|
4
5
|
import { CANCELLED } from './definitions';
|
|
5
6
|
/**
|
|
6
7
|
* Rejection shape for a cancelled compress. `code` matches what `call.reject`
|
|
7
8
|
* puts on the error object on iOS/Android, so one `error.code === 'CANCELLED'`
|
|
8
|
-
* check works on every platform.
|
|
9
|
+
* check works on every platform — and on both web engines below.
|
|
9
10
|
*/
|
|
10
11
|
class CancelledError extends Error {
|
|
11
12
|
constructor() {
|
|
@@ -14,12 +15,30 @@ class CancelledError extends Error {
|
|
|
14
15
|
this.name = 'CancelledError';
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
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 },
|
|
32
|
+
};
|
|
33
|
+
const WEBCODECS_QUANTIZER = 26;
|
|
34
|
+
const FPS_CAP = 30;
|
|
17
35
|
const CORE_BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
|
18
36
|
/**
|
|
19
37
|
* Cancelling means terminating the worker, which drops the loaded core with
|
|
20
38
|
* it — so without this the next compress re-downloads ~30 MB of wasm and
|
|
21
39
|
* "cancel" quietly costs more than it saves. Module scope, not instance: the
|
|
22
|
-
* bytes are identical for every instance and every job.
|
|
40
|
+
* bytes are identical for every instance and every job. Only the ffmpeg
|
|
41
|
+
* fallback ever pays this download; the WebCodecs primary path needs no core.
|
|
23
42
|
*/
|
|
24
43
|
let corePromise = null;
|
|
25
44
|
const loadCoreURLs = () => {
|
|
@@ -43,10 +62,167 @@ export class NativeVideoCompressorWeb extends WebPlugin {
|
|
|
43
62
|
super(...arguments);
|
|
44
63
|
this.ffmpeg = null;
|
|
45
64
|
this.isLoaded = false;
|
|
65
|
+
/** The mediabunny conversion running now, so `cancel()` can abort it. */
|
|
66
|
+
this.activeConversion = null;
|
|
46
67
|
/** The job `compressVideo` is running right now, for `cancel({ jobId })`. */
|
|
47
68
|
this.currentJobId = null;
|
|
48
69
|
this.cancelRequested = false;
|
|
49
70
|
}
|
|
71
|
+
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.
|
|
76
|
+
return { success: true, message: 'ready' };
|
|
77
|
+
}
|
|
78
|
+
async compressVideo(options) {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
this.currentJobId = (_a = options.jobId) !== null && _a !== void 0 ? _a : 'default';
|
|
81
|
+
this.cancelRequested = false;
|
|
82
|
+
// Checked at every stage boundary, not just around the encode: cancelling
|
|
83
|
+
// an engine only reaches work already handed to it, so a cancel during the
|
|
84
|
+
// source fetch or before init has nothing to interrupt and would otherwise
|
|
85
|
+
// let the compress carry on as if nothing had happened.
|
|
86
|
+
const throwIfCancelled = () => {
|
|
87
|
+
if (this.cancelRequested)
|
|
88
|
+
throw new CancelledError();
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
throwIfCancelled();
|
|
92
|
+
const blob = await this.fetchSourceBlob(options.sourcePath);
|
|
93
|
+
throwIfCancelled();
|
|
94
|
+
const quality = (_b = options.quality) !== null && _b !== void 0 ? _b : 'MEDIUM';
|
|
95
|
+
if (await this.canUseWebCodecs(quality)) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await this.compressWithWebCodecs(blob, quality, throwIfCancelled);
|
|
98
|
+
if (result)
|
|
99
|
+
return result;
|
|
100
|
+
// null → conversion invalid / tracks discarded → fall back to ffmpeg.
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if ((e === null || e === void 0 ? void 0 : e.code) === CANCELLED)
|
|
104
|
+
throw e; // user cancel: never fall back
|
|
105
|
+
console.warn('[compress] WebCodecs failed, falling back to ffmpeg:', e);
|
|
106
|
+
throwIfCancelled();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return await this.compressWithFfmpeg(blob, quality, throwIfCancelled);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if ((error === null || error === void 0 ? void 0 : error.code) === CANCELLED) {
|
|
113
|
+
console.log('[compress] cancelled');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.error('[compress] failed:', error);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
this.currentJobId = null;
|
|
122
|
+
this.cancelRequested = false;
|
|
123
|
+
this.activeConversion = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Kill whichever engine is mid-compress. Only one is ever active at a time,
|
|
128
|
+
* so acting on both is safe.
|
|
129
|
+
*
|
|
130
|
+
* WebCodecs: `conversion.cancel()` makes the in-flight `execute()` reject
|
|
131
|
+
* with `ConversionCanceledError`, surfaced as a `CancelledError` below.
|
|
132
|
+
* ffmpeg: `exec()` runs synchronously inside the worker and never reaches a
|
|
133
|
+
* message poll while encoding, so `worker.terminate()` is the only way to
|
|
134
|
+
* stop it — its cost is the loaded core, which is why the core URLs are
|
|
135
|
+
* cached above.
|
|
136
|
+
*/
|
|
137
|
+
async cancel(options) {
|
|
138
|
+
var _a;
|
|
139
|
+
if (!this.currentJobId)
|
|
140
|
+
return { cancelled: false };
|
|
141
|
+
if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJobId)
|
|
142
|
+
return { cancelled: false };
|
|
143
|
+
// Set even when neither engine exists yet: the job may still be fetching
|
|
144
|
+
// the source or inside loadFFmpeg, which read this flag at their boundaries.
|
|
145
|
+
this.cancelRequested = true;
|
|
146
|
+
const conversion = this.activeConversion;
|
|
147
|
+
this.activeConversion = null;
|
|
148
|
+
// Fire-and-forget: the CancelledError surfaces from the execute() rejection,
|
|
149
|
+
// not from here; awaiting would only make cancel() slower.
|
|
150
|
+
conversion === null || conversion === void 0 ? void 0 : conversion.cancel().catch(() => undefined);
|
|
151
|
+
(_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
|
|
152
|
+
this.ffmpeg = null;
|
|
153
|
+
this.isLoaded = false;
|
|
154
|
+
return { cancelled: true };
|
|
155
|
+
}
|
|
156
|
+
async fetchSourceBlob(sourcePath) {
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetch(sourcePath);
|
|
159
|
+
return await response.blob();
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.error('[compress] Error fetching source file:', err);
|
|
163
|
+
throw new Error('Could not fetch source video file');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async canUseWebCodecs(quality) {
|
|
167
|
+
try {
|
|
168
|
+
const { bitrate } = WEBCODECS_LADDER[quality];
|
|
169
|
+
return await canEncodeVideo('avc', {
|
|
170
|
+
quality: new Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (_a) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
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;
|
|
184
|
+
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
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (_a) {
|
|
194
|
+
frameRate = undefined;
|
|
195
|
+
}
|
|
196
|
+
throwIfCancelled();
|
|
197
|
+
const conversion = await Conversion.init({
|
|
198
|
+
input,
|
|
199
|
+
output,
|
|
200
|
+
video: Object.assign({ height, quality: new Quality({ quantizer: WEBCODECS_QUANTIZER, bitrate }), hardwareAcceleration: 'prefer-hardware' }, (frameRate ? { frameRate } : {})),
|
|
201
|
+
});
|
|
202
|
+
if (!conversion.isValid) {
|
|
203
|
+
console.warn('[compress] WebCodecs conversion invalid:', conversion.discardedTracks.map((t) => t.reason));
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
this.activeConversion = conversion;
|
|
207
|
+
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
208
|
+
conversion.onProgress = (progress) => {
|
|
209
|
+
this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
|
|
210
|
+
};
|
|
211
|
+
throwIfCancelled();
|
|
212
|
+
try {
|
|
213
|
+
await conversion.execute();
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (this.cancelRequested || e instanceof ConversionCanceledError)
|
|
217
|
+
throw new CancelledError();
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
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) };
|
|
225
|
+
}
|
|
50
226
|
async loadFFmpeg() {
|
|
51
227
|
if (this.isLoaded && this.ffmpeg)
|
|
52
228
|
return;
|
|
@@ -58,18 +234,13 @@ export class NativeVideoCompressorWeb extends WebPlugin {
|
|
|
58
234
|
// after which every compressVideo threw 'FFmpeg failed to load' for the
|
|
59
235
|
// rest of the page's life.
|
|
60
236
|
const instance = new FFmpeg();
|
|
61
|
-
// Listen to FFmpeg progress and map it to capacitor plugin events
|
|
62
237
|
instance.on('progress', ({ progress }) => {
|
|
63
|
-
// progress is a fraction between 0 and 1
|
|
64
238
|
this.notifyListeners('onProgress', { status: 'progress', percent: Math.round(progress * 100) });
|
|
65
239
|
});
|
|
66
240
|
instance.on('log', ({ message }) => {
|
|
67
241
|
console.log('[FFmpeg Log]', message);
|
|
68
242
|
});
|
|
69
243
|
try {
|
|
70
|
-
// Use standard single-threaded version for best compatibility across browsers
|
|
71
|
-
// without requiring complex SharedArrayBuffer headers configuration
|
|
72
|
-
console.log('Starting to load FFmpeg core from:', CORE_BASE_URL);
|
|
73
244
|
await instance.load(await loadCoreURLs());
|
|
74
245
|
}
|
|
75
246
|
catch (e) {
|
|
@@ -85,194 +256,101 @@ export class NativeVideoCompressorWeb extends WebPlugin {
|
|
|
85
256
|
}
|
|
86
257
|
this.ffmpeg = instance;
|
|
87
258
|
this.isLoaded = true;
|
|
88
|
-
console.log('FFmpeg core loaded successfully');
|
|
89
259
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
*
|
|
93
|
-
* `ffmpeg.exec()` runs *synchronously* inside the worker (see
|
|
94
|
-
* @ffmpeg/ffmpeg's worker.js: `ffmpeg.exec(...args)` is not awaited), so the
|
|
95
|
-
* worker never reaches a message poll while encoding. The AbortSignal that
|
|
96
|
-
* `exec()` accepts only rejects the main-thread promise; the encode keeps
|
|
97
|
-
* burning CPU behind it. `worker.terminate()` is what actually stops it.
|
|
98
|
-
*
|
|
99
|
-
* The cost is the loaded core, which is why the core URLs are cached above.
|
|
100
|
-
*/
|
|
101
|
-
async cancel(options) {
|
|
102
|
-
var _a;
|
|
103
|
-
if (!this.currentJobId)
|
|
104
|
-
return { cancelled: false };
|
|
105
|
-
if ((options === null || options === void 0 ? void 0 : options.jobId) && options.jobId !== this.currentJobId)
|
|
106
|
-
return { cancelled: false };
|
|
107
|
-
// Set even when there is no instance yet: the job may still be inside
|
|
108
|
-
// loadFFmpeg, which reads this flag before publishing what it built.
|
|
109
|
-
this.cancelRequested = true;
|
|
110
|
-
(_a = this.ffmpeg) === null || _a === void 0 ? void 0 : _a.terminate();
|
|
111
|
-
this.ffmpeg = null;
|
|
112
|
-
this.isLoaded = false;
|
|
113
|
-
return { cancelled: true };
|
|
114
|
-
}
|
|
115
|
-
async initialize() {
|
|
116
|
-
try {
|
|
117
|
-
if (this.isLoaded) {
|
|
118
|
-
return { success: true, message: 'Already initialized' };
|
|
119
|
-
}
|
|
260
|
+
async compressWithFfmpeg(blob, quality, throwIfCancelled) {
|
|
261
|
+
if (!this.isLoaded) {
|
|
120
262
|
await this.loadFFmpeg();
|
|
121
|
-
return { success: true, message: 'Initialization successful' };
|
|
122
263
|
}
|
|
123
|
-
|
|
124
|
-
|
|
264
|
+
throwIfCancelled();
|
|
265
|
+
if (!this.ffmpeg) {
|
|
266
|
+
throw new Error('FFmpeg failed to load');
|
|
125
267
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
this.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
268
|
+
// Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after any
|
|
269
|
+
// 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.
|
|
271
|
+
const ffmpeg = this.ffmpeg;
|
|
272
|
+
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
273
|
+
const inputFileName = 'input.mp4';
|
|
274
|
+
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
|
+
}
|
|
310
|
+
const inputData = new Uint8Array(await blob.arrayBuffer());
|
|
311
|
+
try {
|
|
312
|
+
await ffmpeg.writeFile(inputFileName, inputData);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
136
315
|
if (this.cancelRequested)
|
|
137
316
|
throw new CancelledError();
|
|
138
|
-
|
|
317
|
+
console.error('[FFmpeg] Error writing to FS:', err);
|
|
318
|
+
throw new Error('Could not write to FFmpeg FS');
|
|
319
|
+
}
|
|
320
|
+
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);
|
|
139
328
|
try {
|
|
140
|
-
|
|
141
|
-
await this.loadFFmpeg();
|
|
142
|
-
}
|
|
143
|
-
throwIfCancelled();
|
|
144
|
-
if (!this.ffmpeg) {
|
|
145
|
-
throw new Error('FFmpeg failed to load');
|
|
146
|
-
}
|
|
147
|
-
// Bound once. `this.ffmpeg` is nulled by cancel(), so reading it after
|
|
148
|
-
// any of the four awaits below can hand back null on a reference the
|
|
149
|
-
// compiler narrowed at this line — surfacing a cancel as a TypeError
|
|
150
|
-
// instead of a CancelledError.
|
|
151
|
-
const ffmpeg = this.ffmpeg;
|
|
152
|
-
this.notifyListeners('onProgress', { status: 'started', percent: 0 });
|
|
153
|
-
const { sourcePath, quality = 'MEDIUM' } = options;
|
|
154
|
-
const inputFileName = 'input.mp4';
|
|
155
|
-
const outputFileName = 'output.mp4';
|
|
156
|
-
// Map quality to CRF, Scale, and Audio Bitrate
|
|
157
|
-
let crf = '28';
|
|
158
|
-
let scale = ''; // default keep resolution
|
|
159
|
-
let audioBitrate = '128k';
|
|
160
|
-
switch (quality) {
|
|
161
|
-
case 'VERY_HIGH': // 1080p
|
|
162
|
-
crf = '23';
|
|
163
|
-
scale = '-vf scale=-2:1080';
|
|
164
|
-
audioBitrate = '128k';
|
|
165
|
-
break;
|
|
166
|
-
case 'HIGH': // 720p
|
|
167
|
-
crf = '28';
|
|
168
|
-
scale = '-vf scale=-2:720';
|
|
169
|
-
audioBitrate = '128k';
|
|
170
|
-
break;
|
|
171
|
-
case 'MEDIUM': // 540p
|
|
172
|
-
crf = '30';
|
|
173
|
-
scale = '-vf scale=-2:540';
|
|
174
|
-
audioBitrate = '96k';
|
|
175
|
-
break;
|
|
176
|
-
case 'LOW': // 480p
|
|
177
|
-
crf = '32';
|
|
178
|
-
scale = '-vf scale=-2:480';
|
|
179
|
-
audioBitrate = '96k';
|
|
180
|
-
break;
|
|
181
|
-
case '360P': // 360p
|
|
182
|
-
crf = '28';
|
|
183
|
-
scale = '-vf scale=-2:360';
|
|
184
|
-
audioBitrate = '64k';
|
|
185
|
-
break;
|
|
186
|
-
case 'VERY_LOW': // 240p
|
|
187
|
-
crf = '35';
|
|
188
|
-
scale = '-vf scale=-2:240';
|
|
189
|
-
audioBitrate = '48k';
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
console.log('[FFmpeg] Fetching source file...', sourcePath);
|
|
193
|
-
let inputData;
|
|
194
|
-
try {
|
|
195
|
-
inputData = await fetchFile(sourcePath);
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
console.error('[FFmpeg] Error fetching source file:', err);
|
|
199
|
-
throw new Error('Could not fetch source video file');
|
|
200
|
-
}
|
|
201
|
-
throwIfCancelled();
|
|
202
|
-
console.log('[FFmpeg] Writing file to FS...', inputData.byteLength, 'bytes');
|
|
203
|
-
try {
|
|
204
|
-
await ffmpeg.writeFile(inputFileName, inputData);
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
if (this.cancelRequested)
|
|
208
|
-
throw new CancelledError();
|
|
209
|
-
console.error('[FFmpeg] Error writing to FS:', err);
|
|
210
|
-
throw new Error('Could not write to FFmpeg FS');
|
|
211
|
-
}
|
|
212
|
-
throwIfCancelled();
|
|
213
|
-
// Construct FFmpeg command array
|
|
214
|
-
const args = ['-i', inputFileName];
|
|
215
|
-
// Video codec and compression parameters (superfast provides much better size than ultrafast)
|
|
216
|
-
args.push('-c:v', 'libx264', '-preset', 'superfast', '-crf', crf);
|
|
217
|
-
// Resize if needed (split by space)
|
|
218
|
-
if (scale) {
|
|
219
|
-
args.push(...scale.split(' '));
|
|
220
|
-
}
|
|
221
|
-
// Audio re-encoding to save space
|
|
222
|
-
args.push('-c:a', 'aac', '-b:a', audioBitrate);
|
|
223
|
-
// Output
|
|
224
|
-
args.push(outputFileName);
|
|
225
|
-
console.log('[FFmpeg] Executing command:', args.join(' '));
|
|
226
|
-
try {
|
|
227
|
-
const retCode = await ffmpeg.exec(args);
|
|
228
|
-
console.log('[FFmpeg] Exec returned code:', retCode);
|
|
229
|
-
}
|
|
230
|
-
catch (err) {
|
|
231
|
-
// terminate() rejects every in-flight call, so a cancel arrives here
|
|
232
|
-
// looking like a generic failure unless we name it.
|
|
233
|
-
if (this.cancelRequested)
|
|
234
|
-
throw new CancelledError();
|
|
235
|
-
console.error('[FFmpeg] Error executing command:', err);
|
|
236
|
-
throw new Error('FFmpeg execution failed');
|
|
237
|
-
}
|
|
238
|
-
throwIfCancelled();
|
|
239
|
-
console.log('[FFmpeg] Reading output file...');
|
|
240
|
-
let data;
|
|
241
|
-
try {
|
|
242
|
-
data = await ffmpeg.readFile(outputFileName);
|
|
243
|
-
}
|
|
244
|
-
catch (err) {
|
|
245
|
-
if (this.cancelRequested)
|
|
246
|
-
throw new CancelledError();
|
|
247
|
-
console.error('[FFmpeg] Error reading output file:', err);
|
|
248
|
-
throw new Error('Could not read FFmpeg output');
|
|
249
|
-
}
|
|
250
|
-
// Free memory
|
|
251
|
-
await ffmpeg.deleteFile(inputFileName);
|
|
252
|
-
await ffmpeg.deleteFile(outputFileName);
|
|
253
|
-
console.log('[FFmpeg] Creating Blob URL from output...', data.byteLength, 'bytes');
|
|
254
|
-
// Create a blob URL for the resulting file
|
|
255
|
-
const blob = new Blob([data], { type: 'video/mp4' });
|
|
256
|
-
const destPath = URL.createObjectURL(blob);
|
|
257
|
-
console.log('[FFmpeg] Compression complete, returning path:', destPath);
|
|
258
|
-
return {
|
|
259
|
-
success: true,
|
|
260
|
-
destPath: destPath,
|
|
261
|
-
};
|
|
329
|
+
await ffmpeg.exec(args);
|
|
262
330
|
}
|
|
263
|
-
catch (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
throw error;
|
|
331
|
+
catch (err) {
|
|
332
|
+
// terminate() rejects every in-flight call, so a cancel arrives here
|
|
333
|
+
// looking like a generic failure unless we name it.
|
|
334
|
+
if (this.cancelRequested)
|
|
335
|
+
throw new CancelledError();
|
|
336
|
+
console.error('[FFmpeg] Error executing command:', err);
|
|
337
|
+
throw new Error('FFmpeg execution failed');
|
|
271
338
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
339
|
+
throwIfCancelled();
|
|
340
|
+
let data;
|
|
341
|
+
try {
|
|
342
|
+
data = await ffmpeg.readFile(outputFileName);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (this.cancelRequested)
|
|
346
|
+
throw new CancelledError();
|
|
347
|
+
console.error('[FFmpeg] Error reading output file:', err);
|
|
348
|
+
throw new Error('Could not read FFmpeg output');
|
|
275
349
|
}
|
|
350
|
+
await ffmpeg.deleteFile(inputFileName);
|
|
351
|
+
await ffmpeg.deleteFile(outputFileName);
|
|
352
|
+
const outBlob = new Blob([data], { type: 'video/mp4' });
|
|
353
|
+
return { success: true, destPath: URL.createObjectURL(outBlob) };
|
|
276
354
|
}
|
|
277
355
|
}
|
|
278
356
|
//# sourceMappingURL=web.js.map
|