@honem/native-video-compressor 0.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.
@@ -0,0 +1,53 @@
1
+ # Electron Implementation
2
+
3
+ This directory contains the Electron-specific implementation of the video compressor plugin.
4
+
5
+ ## Setup
6
+
7
+ To use this plugin in an Electron app, you need to:
8
+
9
+ 1. Install the required dependencies:
10
+ ```bash
11
+ npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg
12
+ ```
13
+
14
+ 2. Register the plugin in your Electron main process:
15
+
16
+ The plugin uses Capacitor's standard plugin registration. In your Electron main process, you can register it manually if needed:
17
+
18
+ ```typescript
19
+ import { VideoCompressorElectron } from '@honem/native-video-compressor/electron';
20
+ import { Capacitor } from '@capacitor/core';
21
+
22
+ // Register the Electron implementation
23
+ if (Capacitor.getPlatform() === 'electron') {
24
+ // The plugin will be automatically registered when imported
25
+ // If you need manual registration, you can do:
26
+ const compressor = new VideoCompressorElectron();
27
+ // Register with Capacitor's bridge if needed
28
+ }
29
+ ```
30
+
31
+ Alternatively, you can use the plugin directly in your renderer process:
32
+
33
+ ```typescript
34
+ import { VideoCompressor } from '@honem/native-video-compressor';
35
+
36
+ // The plugin will automatically use the Electron implementation
37
+ // when running in an Electron environment
38
+ const result = await VideoCompressor.compressVideo({
39
+ inputPath: '/path/to/video.mp4',
40
+ quality: 'medium'
41
+ });
42
+ ```
43
+
44
+ **Note**: The plugin automatically detects the Electron platform and uses the appropriate implementation. No additional registration is typically required.
45
+
46
+ ## FFmpeg
47
+
48
+ The Electron implementation uses FFmpeg for video compression. It will:
49
+ 1. First try to use the bundled FFmpeg from `@ffmpeg-installer/ffmpeg`
50
+ 2. Fall back to system FFmpeg if available in PATH
51
+
52
+ Make sure FFmpeg is available either via the npm package or installed on the system.
53
+
@@ -0,0 +1,4 @@
1
+ // Electron plugin registration
2
+ // This file is used when the plugin is loaded in an Electron environment
3
+ export { VideoCompressorElectron } from './video-compressor';
4
+
@@ -0,0 +1,202 @@
1
+ import type { VideoCompressorPlugin, CompressionOptions, CompressionResult, DeleteFileOptions, DeleteFileResult } from '../../src/definitions';
2
+ import { WebPlugin } from '@capacitor/core';
3
+ import * as ffmpeg from 'fluent-ffmpeg';
4
+ import * as ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import { promisify } from 'util';
9
+
10
+ const stat = promisify(fs.stat);
11
+ const unlink = promisify(fs.unlink);
12
+
13
+ let ffmpegPath: string | null = null;
14
+
15
+ try {
16
+ ffmpegPath = ffmpegInstaller.path;
17
+ ffmpeg.setFfmpegPath(ffmpegPath);
18
+ } catch {
19
+ try {
20
+ ffmpegPath = 'ffmpeg';
21
+ } catch {
22
+ console.warn('FFmpeg not found. Please install FFmpeg or use @ffmpeg-installer/ffmpeg');
23
+ }
24
+ }
25
+
26
+ export class VideoCompressorElectron extends WebPlugin implements VideoCompressorPlugin {
27
+ constructor() {
28
+ super();
29
+ }
30
+
31
+ async compressVideo(options: CompressionOptions): Promise<CompressionResult> {
32
+ if (!ffmpegPath) {
33
+ throw new Error('FFmpeg is not available. Please install FFmpeg or use @ffmpeg-installer/ffmpeg');
34
+ }
35
+
36
+ const startTime = Date.now();
37
+ const inputPath = this.resolveFilePath(options.inputPath);
38
+ const outputPath = options.outputPath
39
+ ? this.resolveFilePath(options.outputPath)
40
+ : this.generateTempOutputPath(options.format || 'mp4');
41
+
42
+ try {
43
+ await stat(inputPath);
44
+ } catch {
45
+ throw new Error(`Input file not found: ${inputPath}`);
46
+ }
47
+
48
+ const inputStats = await stat(inputPath);
49
+ const originalSize = inputStats.size;
50
+
51
+ const { targetBitrate, targetWidth, targetHeight, videoCodec, audioCodec, format } =
52
+ this.getCompressionParams(options);
53
+
54
+ try {
55
+ await stat(outputPath);
56
+ await unlink(outputPath);
57
+ } catch {
58
+ // File doesn't exist, which is fine
59
+ }
60
+
61
+ return new Promise((resolve, reject) => {
62
+ const command = ffmpeg(inputPath)
63
+ .videoCodec(videoCodec)
64
+ .audioCodec(audioCodec)
65
+ .outputOptions([
66
+ '-preset fast',
67
+ '-movflags +faststart',
68
+ ]);
69
+
70
+ if (targetBitrate) {
71
+ command.videoBitrate(targetBitrate);
72
+ }
73
+
74
+ if (targetWidth && targetHeight) {
75
+ command.size(`${targetWidth}x${targetHeight}`);
76
+ }
77
+
78
+ command.format(format);
79
+ command.output(outputPath);
80
+
81
+ command.on('error', (error) => {
82
+ reject(new Error(`FFmpeg error: ${error.message}`));
83
+ });
84
+
85
+ command.on('end', async () => {
86
+ try {
87
+ const outputStats = await stat(outputPath);
88
+ const compressedSize = outputStats.size;
89
+ const duration = Date.now() - startTime;
90
+ const compressionRatio = originalSize / compressedSize;
91
+
92
+ resolve({
93
+ outputPath,
94
+ originalSize,
95
+ compressedSize,
96
+ compressionRatio,
97
+ duration,
98
+ });
99
+ } catch (error) {
100
+ reject(new Error(`Failed to get output file stats: ${error}`));
101
+ }
102
+ });
103
+
104
+ command.run();
105
+ });
106
+ }
107
+
108
+ async deleteFile(options: DeleteFileOptions): Promise<DeleteFileResult> {
109
+ try {
110
+ const filePath = this.resolveFilePath(options.path);
111
+ await stat(filePath);
112
+ await unlink(filePath);
113
+ return { success: true };
114
+ } catch {
115
+ return { success: false };
116
+ }
117
+ }
118
+
119
+ private resolveFilePath(filePath: string): string {
120
+ if (filePath.startsWith('file://')) {
121
+ return filePath.replace('file://', '');
122
+ }
123
+
124
+ if (path.isAbsolute(filePath)) {
125
+ return filePath;
126
+ }
127
+
128
+ return path.resolve(process.cwd(), filePath);
129
+ }
130
+
131
+ private generateTempOutputPath(format: string): string {
132
+ const tempDir = os.tmpdir();
133
+ const extension = this.getFileExtension(format);
134
+ const uniqueName = `compressed_${Date.now()}.${extension}`;
135
+ return path.join(tempDir, uniqueName);
136
+ }
137
+
138
+ private getFileExtension(format: string): string {
139
+ switch (format.toLowerCase()) {
140
+ case 'mov':
141
+ return 'mov';
142
+ case 'm4v':
143
+ return 'm4v';
144
+ case 'webm':
145
+ return 'webm';
146
+ case '3gp':
147
+ return '3gp';
148
+ case 'mp4':
149
+ default:
150
+ return 'mp4';
151
+ }
152
+ }
153
+
154
+ private getCompressionParams(options: CompressionOptions) {
155
+ let targetBitrate: number | undefined;
156
+ let targetWidth: number | undefined;
157
+ let targetHeight: number | undefined;
158
+ let videoCodec = 'libx264';
159
+ let audioCodec = 'aac';
160
+ const format = options.format || 'mp4';
161
+
162
+ switch (options.quality) {
163
+ case 'low':
164
+ targetBitrate = 800;
165
+ targetWidth = 854;
166
+ targetHeight = 480;
167
+ break;
168
+ case 'high':
169
+ targetBitrate = options.bitrate ? options.bitrate / 1000 : 8000;
170
+ targetWidth = options.width;
171
+ targetHeight = options.height;
172
+ break;
173
+ case 'custom':
174
+ targetBitrate = options.bitrate ? options.bitrate / 1000 : 2000;
175
+ targetWidth = options.width;
176
+ targetHeight = options.height;
177
+ break;
178
+ default: // 'medium'
179
+ targetBitrate = 1500;
180
+ targetWidth = 1280;
181
+ targetHeight = 720;
182
+ break;
183
+ }
184
+
185
+ if (format === 'webm') {
186
+ videoCodec = 'libvpx-vp9';
187
+ audioCodec = 'libopus';
188
+ } else if (format === '3gp') {
189
+ videoCodec = 'h263';
190
+ audioCodec = 'amr_nb';
191
+ }
192
+
193
+ return {
194
+ targetBitrate,
195
+ targetWidth,
196
+ targetHeight,
197
+ videoCodec,
198
+ audioCodec,
199
+ format,
200
+ };
201
+ }
202
+ }
@@ -0,0 +1,7 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <Capacitor/Capacitor.h>
3
+
4
+ CAP_PLUGIN(VideoCompressorPlugin, "VideoCompressor",
5
+ CAP_PLUGIN_METHOD(compressVideo, CAPPluginReturnPromise);
6
+ CAP_PLUGIN_METHOD(deleteFile, CAPPluginReturnPromise);
7
+ )
@@ -0,0 +1,378 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import UIKit
4
+
5
+ enum CompressionError: Error {
6
+ case invalidInputPath
7
+ case invalidOutputPath
8
+ case assetLoadFailed
9
+ case exportSessionCreationFailed
10
+ case exportFailed(String)
11
+ case fileNotFound
12
+ case unsupportedFormat
13
+ }
14
+
15
+ enum CompressionResult {
16
+ case success(outputPath: String, originalSize: Int64, compressedSize: Int64)
17
+ case failure(Error)
18
+ }
19
+
20
+ class VideoCompressor {
21
+
22
+ private let compressedPrefix = "compressed_"
23
+
24
+ func compressVideo(
25
+ inputPath: String,
26
+ outputPath: String?,
27
+ quality: String,
28
+ bitrate: Int?,
29
+ width: Int?,
30
+ height: Int?,
31
+ format: String?,
32
+ completion: @escaping (CompressionResult) -> Void
33
+ ) {
34
+ guard let inputURL = resolveFilePath(inputPath) else {
35
+ completion(.failure(CompressionError.invalidInputPath))
36
+ return
37
+ }
38
+
39
+ guard FileManager.default.fileExists(atPath: inputURL.path) else {
40
+ completion(.failure(CompressionError.fileNotFound))
41
+ return
42
+ }
43
+
44
+ let originalSize: Int64
45
+ do {
46
+ let attributes = try FileManager.default.attributesOfItem(atPath: inputURL.path)
47
+ originalSize = attributes[.size] as? Int64 ?? 0
48
+ } catch {
49
+ completion(.failure(error))
50
+ return
51
+ }
52
+
53
+ let asset = AVAsset(url: inputURL)
54
+
55
+ let outputFormat: String
56
+ if let format = format, !format.isEmpty {
57
+ outputFormat = format
58
+ } else {
59
+ let inputExtension = inputURL.pathExtension.lowercased()
60
+ switch inputExtension {
61
+ case "mov":
62
+ outputFormat = "mov"
63
+ case "m4v":
64
+ outputFormat = "m4v"
65
+ case "mp4":
66
+ outputFormat = "mp4"
67
+ default:
68
+ outputFormat = "mp4"
69
+ }
70
+ }
71
+
72
+ let outputURL: URL
73
+ if let outputPath = outputPath, let resolvedOutputURL = resolveFilePath(outputPath) {
74
+ outputURL = resolvedOutputURL
75
+ } else {
76
+ let tempDir = FileManager.default.temporaryDirectory
77
+ let fileExtension = getFileExtension(for: outputFormat)
78
+ let uniqueName = "\(compressedPrefix)\(Int(Date().timeIntervalSince1970 * 1000)).\(fileExtension)"
79
+ outputURL = tempDir.appendingPathComponent(uniqueName)
80
+ }
81
+
82
+ guard let videoTrack = asset.tracks(withMediaType: .video).first else {
83
+ completion(.failure(CompressionError.assetLoadFailed))
84
+ return
85
+ }
86
+
87
+ // naturalSize doesn't account for transform (rotation), calculate actual displayed size
88
+ let naturalSize = videoTrack.naturalSize
89
+ let preferredTransform = videoTrack.preferredTransform
90
+ let rect = CGRect(origin: .zero, size: naturalSize)
91
+ let transformedRect = rect.applying(preferredTransform)
92
+ let actualSize = CGSize(width: abs(transformedRect.width), height: abs(transformedRect.height))
93
+
94
+ let originalWidth = Int(actualSize.width)
95
+ let originalHeight = Int(actualSize.height)
96
+ let originalAspectRatio = Double(originalWidth) / Double(originalHeight)
97
+
98
+ func calculateTargetDimensions(
99
+ originalWidth: Int,
100
+ originalHeight: Int,
101
+ maxWidth: Int?,
102
+ maxHeight: Int?,
103
+ customWidth: Int?,
104
+ customHeight: Int?
105
+ ) -> (width: Int, height: Int) {
106
+ if let cw = customWidth, let ch = customHeight {
107
+ return (width: cw, height: ch)
108
+ }
109
+
110
+ if let cw = customWidth {
111
+ let calculatedHeight = Int(Double(cw) / originalAspectRatio)
112
+ return (width: min(cw, originalWidth), height: min(calculatedHeight, originalHeight))
113
+ }
114
+
115
+ if let ch = customHeight {
116
+ let calculatedWidth = Int(Double(ch) * originalAspectRatio)
117
+ return (width: min(calculatedWidth, originalWidth), height: min(ch, originalHeight))
118
+ }
119
+
120
+ var targetWidth = originalWidth
121
+ var targetHeight = originalHeight
122
+
123
+ if let maxW = maxWidth, originalWidth > maxW {
124
+ targetWidth = maxW
125
+ targetHeight = Int(Double(maxW) / originalAspectRatio)
126
+ }
127
+
128
+ if let maxH = maxHeight, targetHeight > maxH {
129
+ targetHeight = maxH
130
+ targetWidth = Int(Double(maxH) * originalAspectRatio)
131
+ }
132
+
133
+ return (width: min(targetWidth, originalWidth), height: min(targetHeight, originalHeight))
134
+ }
135
+
136
+ let exportPreset: String
137
+ let targetBitrate: Int
138
+ let targetDimensions: (width: Int, height: Int)
139
+
140
+ switch quality {
141
+ case "low":
142
+ exportPreset = AVAssetExportPresetLowQuality
143
+ targetBitrate = 800_000
144
+ targetDimensions = calculateTargetDimensions(
145
+ originalWidth: originalWidth,
146
+ originalHeight: originalHeight,
147
+ maxWidth: 854,
148
+ maxHeight: 480,
149
+ customWidth: width,
150
+ customHeight: height
151
+ )
152
+
153
+ case "high":
154
+ exportPreset = AVAssetExportPresetHighestQuality
155
+ targetBitrate = 8_000_000
156
+ targetDimensions = calculateTargetDimensions(
157
+ originalWidth: originalWidth,
158
+ originalHeight: originalHeight,
159
+ maxWidth: nil,
160
+ maxHeight: nil,
161
+ customWidth: width,
162
+ customHeight: height
163
+ )
164
+
165
+ case "custom":
166
+ exportPreset = AVAssetExportPresetMediumQuality
167
+ targetBitrate = bitrate ?? 2_000_000
168
+ targetDimensions = calculateTargetDimensions(
169
+ originalWidth: originalWidth,
170
+ originalHeight: originalHeight,
171
+ maxWidth: nil,
172
+ maxHeight: nil,
173
+ customWidth: width,
174
+ customHeight: height
175
+ )
176
+
177
+ default: // "medium"
178
+ // AVAssetExportSession preset controls bitrate, not targetBitrate variable
179
+ exportPreset = AVAssetExportPresetMediumQuality
180
+ targetBitrate = 1_500_000
181
+ targetDimensions = calculateTargetDimensions(
182
+ originalWidth: originalWidth,
183
+ originalHeight: originalHeight,
184
+ maxWidth: 1280,
185
+ maxHeight: 720,
186
+ customWidth: width,
187
+ customHeight: height
188
+ )
189
+ }
190
+
191
+ let targetWidth = targetDimensions.width
192
+ let targetHeight = targetDimensions.height
193
+
194
+ let composition = AVMutableComposition()
195
+
196
+ guard let compositionVideoTrack = composition.addMutableTrack(
197
+ withMediaType: .video,
198
+ preferredTrackID: kCMPersistentTrackID_Invalid
199
+ ) else {
200
+ completion(.failure(CompressionError.exportSessionCreationFailed))
201
+ return
202
+ }
203
+
204
+ do {
205
+ try compositionVideoTrack.insertTimeRange(
206
+ CMTimeRange(start: .zero, duration: asset.duration),
207
+ of: videoTrack,
208
+ at: .zero
209
+ )
210
+ } catch {
211
+ completion(.failure(error))
212
+ return
213
+ }
214
+
215
+ compositionVideoTrack.preferredTransform = videoTrack.preferredTransform
216
+
217
+ if let audioTrack = asset.tracks(withMediaType: .audio).first {
218
+ if let compositionAudioTrack = composition.addMutableTrack(
219
+ withMediaType: .audio,
220
+ preferredTrackID: kCMPersistentTrackID_Invalid
221
+ ) {
222
+ do {
223
+ try compositionAudioTrack.insertTimeRange(
224
+ CMTimeRange(start: .zero, duration: asset.duration),
225
+ of: audioTrack,
226
+ at: .zero
227
+ )
228
+ } catch {
229
+ print("Warning: Failed to copy audio track: \(error)")
230
+ }
231
+ }
232
+ }
233
+
234
+ let videoComposition = AVMutableVideoComposition()
235
+ videoComposition.renderSize = CGSize(width: targetWidth, height: targetHeight)
236
+ videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
237
+
238
+ let instruction = AVMutableVideoCompositionInstruction()
239
+ instruction.timeRange = CMTimeRange(start: .zero, duration: asset.duration)
240
+
241
+ let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
242
+
243
+ // Transform calculation: rotation → scale → center
244
+ let compositionNaturalSize = compositionVideoTrack.naturalSize
245
+ let compositionTransform = compositionVideoTrack.preferredTransform
246
+ let compositionRect = CGRect(origin: .zero, size: compositionNaturalSize)
247
+ let transformedCompositionRect = compositionRect.applying(compositionTransform)
248
+ let compositionDisplaySize = CGSize(width: abs(transformedCompositionRect.width), height: abs(transformedCompositionRect.height))
249
+
250
+ let scaleX = Double(targetWidth) / Double(compositionDisplaySize.width)
251
+ let scaleY = Double(targetHeight) / Double(compositionDisplaySize.height)
252
+ let scale = min(scaleX, scaleY)
253
+
254
+ var finalTransform = compositionTransform
255
+ finalTransform = finalTransform.scaledBy(x: CGFloat(scale), y: CGFloat(scale))
256
+
257
+ let scaledWidth = compositionDisplaySize.width * CGFloat(scale)
258
+ let scaledHeight = compositionDisplaySize.height * CGFloat(scale)
259
+ let offsetX = (CGFloat(targetWidth) - scaledWidth) / 2.0
260
+ let offsetY = (CGFloat(targetHeight) - scaledHeight) / 2.0
261
+ finalTransform = finalTransform.translatedBy(x: offsetX, y: offsetY)
262
+
263
+ layerInstruction.setTransform(finalTransform, at: .zero)
264
+
265
+ instruction.layerInstructions = [layerInstruction]
266
+ videoComposition.instructions = [instruction]
267
+
268
+ let finalAsset: AVAsset = composition
269
+
270
+ guard let exportSession = AVAssetExportSession(asset: finalAsset, presetName: exportPreset) else {
271
+ completion(.failure(CompressionError.exportSessionCreationFailed))
272
+ return
273
+ }
274
+
275
+ exportSession.outputURL = outputURL
276
+ exportSession.outputFileType = getAVFileType(for: outputFormat)
277
+ exportSession.shouldOptimizeForNetworkUse = true
278
+ exportSession.videoComposition = videoComposition
279
+
280
+ exportSession.exportAsynchronously {
281
+ switch exportSession.status {
282
+ case .completed:
283
+ let compressedSize: Int64
284
+ do {
285
+ let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
286
+ compressedSize = attributes[.size] as? Int64 ?? 0
287
+ } catch {
288
+ completion(.failure(error))
289
+ return
290
+ }
291
+
292
+ completion(.success(
293
+ outputPath: outputURL.path,
294
+ originalSize: originalSize,
295
+ compressedSize: compressedSize
296
+ ))
297
+
298
+ case .failed:
299
+ let error = exportSession.error ?? CompressionError.exportFailed("Unknown export error")
300
+ completion(.failure(error))
301
+
302
+ case .cancelled:
303
+ completion(.failure(CompressionError.exportFailed("Export was cancelled")))
304
+
305
+ default:
306
+ completion(.failure(CompressionError.exportFailed("Export failed with status: \(exportSession.status.rawValue)")))
307
+ }
308
+ }
309
+ }
310
+
311
+ func deleteFile(_ path: String) -> Bool {
312
+ guard let url = resolveFilePath(path) else {
313
+ return false
314
+ }
315
+
316
+ do {
317
+ if FileManager.default.fileExists(atPath: url.path) {
318
+ try FileManager.default.removeItem(at: url)
319
+ return true
320
+ }
321
+ return false
322
+ } catch {
323
+ print("Failed to delete file: \(error)")
324
+ return false
325
+ }
326
+ }
327
+
328
+ private func resolveFilePath(_ path: String) -> URL? {
329
+ if path.hasPrefix("file://") {
330
+ return URL(string: path)
331
+ }
332
+
333
+ if path.hasPrefix("/") {
334
+ return URL(fileURLWithPath: path)
335
+ }
336
+
337
+ // Capacitor file paths: capacitor://localhost/_capacitor_file_/...
338
+ if path.contains("_capacitor_file_") {
339
+ let fileManager = FileManager.default
340
+ let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
341
+ let pathComponents = path.components(separatedBy: "_capacitor_file_")
342
+ if pathComponents.count > 1 {
343
+ let relativePath = pathComponents[1]
344
+ return documentsPath.appendingPathComponent(relativePath)
345
+ }
346
+ }
347
+
348
+ let fileManager = FileManager.default
349
+ let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
350
+ return documentsPath.appendingPathComponent(path)
351
+ }
352
+
353
+ private func getFileExtension(for format: String) -> String {
354
+ switch format.lowercased() {
355
+ case "mov":
356
+ return "mov"
357
+ case "m4v":
358
+ return "m4v"
359
+ case "mp4":
360
+ return "mp4"
361
+ default:
362
+ return "mp4"
363
+ }
364
+ }
365
+
366
+ private func getAVFileType(for format: String) -> AVFileType {
367
+ switch format.lowercased() {
368
+ case "mov":
369
+ return .mov
370
+ case "m4v":
371
+ return .m4v
372
+ case "mp4":
373
+ return .mp4
374
+ default:
375
+ return .mp4
376
+ }
377
+ }
378
+ }