capacitor-native-video-compressor 1.0.3 → 1.0.5

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.
@@ -6,123 +6,342 @@ import UIKit
6
6
  @objc(NativeVideoCompressorPlugin)
7
7
  public class NativeVideoCompressorPlugin: CAPPlugin, CAPBridgedPlugin {
8
8
 
9
- // --- BẮT ĐẦU PHẦN ĐĂNG KÝ PLUGIN (Thay thế cho file .m cũ) ---
10
9
  public let identifier = "NativeVideoCompressorPlugin"
11
10
  public let jsName = "NativeVideoCompressor"
12
- public let pluginMethods: [CAPPluginMethod] = [
13
- CAPPluginMethod(name: "compressVideo", returnType: CAPPluginReturnPromise)
11
+ // Every method callable from JS must be listed here. `initialize` was
12
+ // missing, so calling it threw "not implemented" on device — harmless
13
+ // (the web build needs it, native doesn't) but it buried a real signal in
14
+ // a console error every single compress.
15
+ public let pluginMethods:[CAPPluginMethod] = [
16
+ CAPPluginMethod(name: "compressVideo", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "cancel", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "initialize", returnType: CAPPluginReturnPromise)
14
19
  ]
15
- // --- KẾT THÚC PHẦN ĐĂNG KÝ ---
16
- private var isCompressing = false
20
+
21
+ /// The job `compressVideo` is running right now, for `cancel(jobId:)`.
22
+ private var currentJobId: String?
23
+ private var isCancelled = false
24
+ /// Held only so `cancel` can stop it; the compress owns it otherwise.
25
+ private var currentReader: AVAssetReader?
26
+ /// Guards the three fields above — `cancel` arrives on the main thread
27
+ /// while the reader is being driven from the compression queues.
28
+ private let stateLock = NSLock()
29
+
30
+ /// Nothing to do natively; AVFoundation needs no warm-up. Answered rather
31
+ /// than left unimplemented so a caller can await it unconditionally.
32
+ @objc func initialize(_ call: CAPPluginCall) {
33
+ call.resolve(["success": true, "message": "No initialization needed on iOS"])
34
+ }
35
+
36
+ /**
37
+ * Stop the running compress.
38
+ *
39
+ * `cancelReading()` is all it takes: the frame loops below already check
40
+ * `reader.status == .reading` every pass, so a cancelled reader sends them
41
+ * down the `else` branch — `markAsFinished()`, `group.leave()` — and the
42
+ * existing completion handler runs. No new exit path, no polling.
43
+ */
44
+ @objc func cancel(_ call: CAPPluginCall) {
45
+ stateLock.lock()
46
+ let running = currentJobId
47
+ let requested = call.getString("jobId")
48
+ // A cancel for a job that isn't the one running would otherwise kill
49
+ // an unrelated caller's compress — there is only ever one.
50
+ guard let running = running, requested == nil || requested == running else {
51
+ stateLock.unlock()
52
+ call.resolve(["cancelled": false])
53
+ return
54
+ }
55
+ isCancelled = true
56
+ let reader = currentReader
57
+ stateLock.unlock()
58
+
59
+ reader?.cancelReading()
60
+ call.resolve(["cancelled": true])
61
+ }
17
62
 
18
63
  @objc func compressVideo(_ call: CAPPluginCall) {
19
- // 1. Lấy đường dẫn từ Javascript truyền vào
20
64
  guard let sourcePath = call.getString("sourcePath") else {
21
65
  call.reject("Missing sourcePath")
22
66
  return
23
67
  }
24
-
25
- // 2. Xử lý đường dẫn (hỗ trợ cả dạng file:// và đường dẫn tuyệt đối)
68
+
69
+ stateLock.lock()
70
+ currentJobId = call.getString("jobId") ?? "default"
71
+ isCancelled = false
72
+ stateLock.unlock()
73
+
26
74
  let videoURL = sourcePath.hasPrefix("file://") ? URL(string: sourcePath)! : URL(fileURLWithPath: sourcePath)
27
-
28
- // Tạo file đầu ra lưu ở thư mục Temp
29
75
  let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("compressed_\(UUID().uuidString).mp4")
30
76
 
31
- // Lấy thông số chất lượng truyền từ giao diện (Mặc định là MEDIUM nếu không có)
32
77
  let qualityStr = call.getString("quality") ?? "MEDIUM"
33
- var presetName: String
34
78
 
35
- print("====== VIDEO COMPRESSION QUALITY RECEIVED: \(qualityStr) ======")
79
+ // 1. Định nghĩa Kích thước và Bitrate (Quyết định dung lượng file)
80
+ var targetWidth: CGFloat
81
+ var targetHeight: CGFloat
82
+ var targetBitrate: Int // Bitrate càng thấp file càng nhẹ
83
+
84
+ print("====== VIDEO COMPRESSION QUALITY: \(qualityStr) ======")
36
85
 
37
86
  switch qualityStr {
38
87
  case "VERY_HIGH":
39
- presetName = AVAssetExportPresetHighestQuality
88
+ targetWidth = 1920; targetHeight = 1080; targetBitrate = 4_500_000 // ~4.5 Mbps
40
89
  case "HIGH":
41
- presetName = AVAssetExportPreset1280x720 // 720p cho mức High để nhận thấy sự giảm dung lượng rõ rệt
90
+ targetWidth = 1280; targetHeight = 720; targetBitrate = 2_500_000 // ~2.5 Mbps
42
91
  case "MEDIUM":
43
- presetName = AVAssetExportPreset960x540 // 540p cho mức Medium
92
+ targetWidth = 960; targetHeight = 540; targetBitrate = 1_500_000 // ~1.5 Mbps (Giảm dung lượng cực tốt)
44
93
  case "LOW":
45
- presetName = AVAssetExportPreset640x480 // 480p cho mức Low
46
- case "360P" :
47
- presetName = AVAssetExportPresetMediumQuality // 360p cho mức
94
+ targetWidth = 640; targetHeight = 480; targetBitrate = 1_000_000 // ~1.0 Mbps
95
+ case "360P":
96
+ targetWidth = 640; targetHeight = 360; targetBitrate = 700_000 // ~0.7 Mbps
48
97
  case "VERY_LOW":
49
- presetName = AVAssetExportPresetLowQuality // 124 cho mức Very Low
98
+ targetWidth = 426; targetHeight = 240; targetBitrate = 400_000 // ~0.4 Mbps
50
99
  default:
51
- presetName = AVAssetExportPreset960x540
100
+ targetWidth = 960; targetHeight = 540; targetBitrate = 1_500_000
52
101
  }
53
102
 
54
103
  let asset = AVAsset(url: videoURL)
55
104
 
56
- // 3. Khởi tạo bộ nén phần cứng với preset tương ứng
57
- guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else {
58
- call.reject("Không thể khởi tạo bộ nén iOS")
59
- return
60
- }
61
-
62
- exportSession.outputURL = outputURL
63
- exportSession.outputFileType = .mp4
64
- exportSession.shouldOptimizeForNetworkUse = true
65
-
66
- // Báo cho giao diện biết là đã bắt đầu nén
67
105
  self.notifyListeners("onProgress", data:["status": "started"])
68
106
 
69
- self.isCompressing = true
70
- let startTime = Date()
71
- self.checkProgress(session: exportSession)
72
-
73
- // Đăng ký Background Task để ứng dụng tiếp tục nén khi thu nhỏ
74
107
  var backgroundTask: UIBackgroundTaskIdentifier = .invalid
75
-
76
- // Cần gọi UIBackgroundTaskIdentifier trên Main Thread (nếu an toàn) hoặc dùng trực tiếp (beginBackgroundTask thread-safe)
77
108
  backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "VideoCompression") {
78
- // Callback khi hết thời gian chạy ngầm do OS cấp
79
109
  UIApplication.shared.endBackgroundTask(backgroundTask)
80
110
  backgroundTask = .invalid
81
111
  }
82
112
 
83
- // 5. Bắt đầu tiến trình nén (Chạy ngầm trên GPU)
84
- exportSession.exportAsynchronously {
85
- self.isCompressing = false
86
- let elapsed = Date().timeIntervalSince(startTime)
87
- print("Compression finished in \(elapsed) seconds. Final status: \(exportSession.status.rawValue)")
113
+ // 2. Gọi hàm nén Custom bằng AVAssetWriter
114
+ self.compressWithAVAssetWriter(
115
+ asset: asset,
116
+ outputURL: outputURL,
117
+ targetWidth: targetWidth,
118
+ targetHeight: targetHeight,
119
+ targetBitrate: targetBitrate,
120
+ call: call,
121
+ backgroundTask: backgroundTask
122
+ )
123
+ }
124
+
125
+ // MARK: - Core Compression Logic (AVAssetWriter)
126
+ private func compressWithAVAssetWriter(
127
+ asset: AVAsset,
128
+ outputURL: URL,
129
+ targetWidth: CGFloat,
130
+ targetHeight: CGFloat,
131
+ targetBitrate: Int,
132
+ call: CAPPluginCall,
133
+ backgroundTask: UIBackgroundTaskIdentifier
134
+ ) {
135
+ guard let videoTrack = asset.tracks(withMediaType: .video).first else {
136
+ clearJobState()
137
+ call.reject("Không tìm thấy track video")
138
+ return
139
+ }
140
+ let audioTrack = asset.tracks(withMediaType: .audio).first
141
+
142
+ do {
143
+ let reader = try AVAssetReader(asset: asset)
144
+ let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
145
+ writer.shouldOptimizeForNetworkUse = true // Tối ưu cho Web/Network
146
+
147
+ stateLock.lock()
148
+ currentReader = reader
149
+ // A cancel that landed between compressVideo and here has nothing
150
+ // to call cancelReading() on, so honour it before any work starts.
151
+ let cancelledEarly = isCancelled
152
+ stateLock.unlock()
153
+
154
+ if cancelledEarly {
155
+ finishCancelled(outputURL: outputURL, call: call, backgroundTask: backgroundTask)
156
+ return
157
+ }
158
+
159
+ // --- XỬ LÝ KÍCH THƯỚC VÀ CHIỀU XOAY (ORIENTATION) ---
160
+ let naturalSize = videoTrack.naturalSize
161
+ let transform = videoTrack.preferredTransform
162
+
163
+ // Tính toán chiều thực tế khi hiển thị
164
+ var visualSize = naturalSize.applying(transform)
165
+ visualSize.width = abs(visualSize.width)
166
+ visualSize.height = abs(visualSize.height)
167
+
168
+ let isVisualPortrait = visualSize.height > visualSize.width
169
+ let boundingWidth = isVisualPortrait ? min(targetWidth, targetHeight) : max(targetWidth, targetHeight)
170
+ let boundingHeight = isVisualPortrait ? max(targetWidth, targetHeight) : min(targetWidth, targetHeight)
88
171
 
89
- DispatchQueue.main.async {
90
- // Kết thúc Background Task khi xử lý xong
172
+ let isNaturalPortrait = naturalSize.height > naturalSize.width
173
+ let outputWidth = isNaturalPortrait ? min(boundingWidth, boundingHeight) : max(boundingWidth, boundingHeight)
174
+ let outputHeight = isNaturalPortrait ? max(boundingWidth, boundingHeight) : min(boundingWidth, boundingHeight)
175
+
176
+ // --- CẤU HÌNH VIDEO ---
177
+ let videoOutputSettings: [String: Any] = [
178
+ kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
179
+ ]
180
+ let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoOutputSettings)
181
+ if reader.canAdd(videoOutput) { reader.add(videoOutput) }
182
+
183
+ let videoInputSettings:[String: Any] = [
184
+ AVVideoCodecKey: AVVideoCodecType.h264,
185
+ AVVideoWidthKey: Int(outputWidth),
186
+ AVVideoHeightKey: Int(outputHeight),
187
+ AVVideoCompressionPropertiesKey:[
188
+ AVVideoAverageBitRateKey: targetBitrate, // ÉP BITRATE TẠI ĐÂY
189
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
190
+ ]
191
+ ]
192
+ let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoInputSettings)
193
+ videoInput.transform = transform // Giữ nguyên chiều xoay gốc
194
+ if writer.canAdd(videoInput) { writer.add(videoInput) }
195
+
196
+ // --- CẤU HÌNH AUDIO ---
197
+ var audioOutput: AVAssetReaderTrackOutput?
198
+ var audioInput: AVAssetWriterInput?
199
+
200
+ if let audioTrack = audioTrack {
201
+ let audioOutputSettings:[String: Any] = [AVFormatIDKey: kAudioFormatLinearPCM]
202
+ audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioOutputSettings)
203
+ if reader.canAdd(audioOutput!) { reader.add(audioOutput!) }
204
+
205
+ let audioInputSettings:[String: Any] = [
206
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
207
+ AVNumberOfChannelsKey: 2,
208
+ AVSampleRateKey: 44100,
209
+ AVEncoderBitRateKey: 128000 // Audio nén về 128kbps
210
+ ]
211
+ audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioInputSettings)
212
+ if writer.canAdd(audioInput!) { writer.add(audioInput!) }
213
+ }
214
+
215
+ // --- BẮT ĐẦU NÉN ---
216
+ writer.startWriting()
217
+ reader.startReading()
218
+ writer.startSession(atSourceTime: .zero)
219
+
220
+ let group = DispatchGroup()
221
+ let videoQueue = DispatchQueue(label: "videoCompressQueue")
222
+ let audioQueue = DispatchQueue(label: "audioCompressQueue")
223
+
224
+ let duration = CMTimeGetSeconds(asset.duration)
225
+ var lastReportedProgress: Int = 0
226
+
227
+ // Xử lý Video Frame
228
+ group.enter()
229
+ videoInput.requestMediaDataWhenReady(on: videoQueue) {
230
+ while videoInput.isReadyForMoreMediaData {
231
+ autoreleasepool {
232
+ if reader.status == .reading, let buffer = videoOutput.copyNextSampleBuffer() {
233
+ videoInput.append(buffer)
234
+
235
+ // Tính toán Progress
236
+ if duration > 0 && !duration.isNaN {
237
+ let pts = CMSampleBufferGetPresentationTimeStamp(buffer)
238
+ let currentSeconds = CMTimeGetSeconds(pts)
239
+ let progress = Int((currentSeconds / duration) * 100)
240
+
241
+ if progress > lastReportedProgress && progress <= 100 {
242
+ lastReportedProgress = progress
243
+ DispatchQueue.main.async {
244
+ self.notifyListeners("onProgress", data:[
245
+ "status": "progress",
246
+ "percent": Double(progress)
247
+ ])
248
+ }
249
+ }
250
+ }
251
+ } else {
252
+ videoInput.markAsFinished()
253
+ group.leave()
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ // Xử lý Audio Frame
260
+ if let aInput = audioInput, let aOutput = audioOutput {
261
+ group.enter()
262
+ aInput.requestMediaDataWhenReady(on: audioQueue) {
263
+ while aInput.isReadyForMoreMediaData {
264
+ autoreleasepool {
265
+ if reader.status == .reading, let buffer = aOutput.copyNextSampleBuffer() {
266
+ aInput.append(buffer)
267
+ } else {
268
+ aInput.markAsFinished()
269
+ group.leave()
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // --- KẾT THÚC ---
277
+ group.notify(queue: .main) {
91
278
  if backgroundTask != .invalid {
92
279
  UIApplication.shared.endBackgroundTask(backgroundTask)
93
- backgroundTask = .invalid
94
280
  }
95
-
96
- switch exportSession.status {
97
- case .completed:
98
- // THÀNH CÔNG! Trả kết quả về cho Nuxt.js
99
- call.resolve([
100
- "success": true,
101
- "destPath": outputURL.path
102
- ])
103
- case .failed:
104
- call.reject("Nén thất bại: \(exportSession.error?.localizedDescription ?? "Lỗi không xác định")")
105
- case .cancelled:
106
- call.reject("Đã hủy nén")
107
- default:
108
- call.reject("Lỗi không xác định")
281
+
282
+ self.stateLock.lock()
283
+ let wasCancelled = self.isCancelled
284
+ self.currentReader = nil
285
+ self.currentJobId = nil
286
+ self.isCancelled = false
287
+ self.stateLock.unlock()
288
+
289
+ if wasCancelled {
290
+ writer.cancelWriting()
291
+ try? FileManager.default.removeItem(at: outputURL)
292
+ call.reject("Đã hủy nén", NativeVideoCompressorPlugin.cancelledCode)
293
+ return
294
+ }
295
+
296
+ if reader.status == .completed {
297
+ writer.finishWriting {
298
+ DispatchQueue.main.async {
299
+ if writer.status == .completed {
300
+ call.resolve([
301
+ "success": true,
302
+ "destPath": outputURL.path
303
+ ])
304
+ } else {
305
+ call.reject("Lỗi khi ghi file: \(writer.error?.localizedDescription ?? "Unknown")")
306
+ }
307
+ }
308
+ }
309
+ } else {
310
+ writer.cancelWriting()
311
+ try? FileManager.default.removeItem(at: outputURL)
312
+ call.reject("Lỗi khi đọc file: \(reader.error?.localizedDescription ?? "Unknown")")
109
313
  }
110
314
  }
315
+
316
+ } catch {
317
+ clearJobState()
318
+ call.reject("Lỗi khởi tạo bộ nén: \(error.localizedDescription)")
111
319
  }
112
320
  }
113
-
114
- private func checkProgress(session: AVAssetExportSession) {
115
- let progress = session.progress * 100
116
- print("Export Progress: \(progress)%") // Log để debug
117
- self.notifyListeners("onProgress", data: [
118
- "status": "progress",
119
- "percent": Double(progress)
120
- ])
121
-
122
- if self.isCompressing && (session.status == .exporting || session.status == .waiting || session.status == .unknown) {
123
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
124
- self?.checkProgress(session: session)
125
- }
321
+
322
+ /// Cancelled before the reader started — nothing to stop, just clean up.
323
+ private func finishCancelled(
324
+ outputURL: URL,
325
+ call: CAPPluginCall,
326
+ backgroundTask: UIBackgroundTaskIdentifier
327
+ ) {
328
+ if backgroundTask != .invalid {
329
+ UIApplication.shared.endBackgroundTask(backgroundTask)
126
330
  }
331
+ clearJobState()
332
+ try? FileManager.default.removeItem(at: outputURL)
333
+ call.reject("Đã hủy nén", NativeVideoCompressorPlugin.cancelledCode)
127
334
  }
128
- }
335
+
336
+ private func clearJobState() {
337
+ stateLock.lock()
338
+ currentReader = nil
339
+ currentJobId = nil
340
+ isCancelled = false
341
+ stateLock.unlock()
342
+ }
343
+
344
+ /// Matches the `CANCELLED` constant the JS side exports, so one
345
+ /// `error.code` check covers web, iOS and Android alike.
346
+ private static let cancelledCode = "CANCELLED"
347
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capacitor-native-video-compressor",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Native video compressor using LightCompressor and AVFoundation",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",