@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.
- package/HonemNativeVideoCompressor.podspec +15 -0
- package/README.md +283 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/honem/nativevideocompressor/VideoCompressor.java +488 -0
- package/android/src/main/java/com/honem/nativevideocompressor/VideoCompressorPlugin.java +72 -0
- package/dist/definitions.d.ts +76 -0
- package/dist/definitions.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/web.d.ts +6 -0
- package/dist/web.js +9 -0
- package/electron/README.md +53 -0
- package/electron/src/index.ts +4 -0
- package/electron/src/video-compressor.ts +202 -0
- package/ios/Plugin/Plugin.m +7 -0
- package/ios/Plugin/VideoCompressor.swift +378 -0
- package/ios/Plugin/VideoCompressorPlugin.swift +61 -0
- package/ios/VideoCompressor.podspec +14 -0
- package/package.json +64 -0
|
@@ -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,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,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
|
+
}
|