capacitor-video-transcoder 0.0.0-stage → 0.0.1

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.
Files changed (32) hide show
  1. package/CapacitorVideoTranscoder.podspec +18 -0
  2. package/Package.swift +39 -0
  3. package/README.md +238 -2
  4. package/android/build.gradle +68 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/TranscodeOptions.kt +122 -0
  7. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoder.kt +265 -0
  8. package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoderPlugin.kt +51 -0
  9. package/android/src/main/res/.gitkeep +0 -0
  10. package/dist/docs.json +221 -0
  11. package/dist/esm/definitions.d.ts +120 -0
  12. package/dist/esm/definitions.js +2 -0
  13. package/dist/esm/definitions.js.map +1 -0
  14. package/dist/esm/index.d.ts +4 -0
  15. package/dist/esm/index.js +7 -0
  16. package/dist/esm/index.js.map +1 -0
  17. package/dist/esm/web.d.ts +6 -0
  18. package/dist/esm/web.js +10 -0
  19. package/dist/esm/web.js.map +1 -0
  20. package/dist/plugin.cjs.js +24 -0
  21. package/dist/plugin.cjs.js.map +1 -0
  22. package/dist/plugin.js +27 -0
  23. package/dist/plugin.js.map +1 -0
  24. package/ios/Package.swift +22 -0
  25. package/ios/Sources/VideoTranscoderCore/Geometry.swift +61 -0
  26. package/ios/Sources/VideoTranscoderCore/TranscodeOptions.swift +107 -0
  27. package/ios/Sources/VideoTranscoderCore/Transcoder.swift +388 -0
  28. package/ios/Sources/VideoTranscoderPlugin/VideoTranscoderPlugin.swift +73 -0
  29. package/ios/Tests/VideoTranscoderCoreTests/OptionsAndGeometryTests.swift +146 -0
  30. package/ios/Tests/VideoTranscoderCoreTests/TranscoderTests.swift +234 -0
  31. package/ios/Tests/VideoTranscoderPluginTests/VideoTranscoderPluginTests.swift +32 -0
  32. package/package.json +101 -4
@@ -0,0 +1,234 @@
1
+ import AVFoundation
2
+ import XCTest
3
+ @testable import VideoTranscoderCore
4
+
5
+ /// End to end: the fixtures in test/fixtures transcoded with AVFoundation, and the output read back. On a Mac with `swift test`
6
+ /// this is the same AVAssetReader/AVAssetWriter path the phone takes, with the Mac's encoder instead of the phone's.
7
+ final class TranscoderTests: XCTestCase {
8
+ static let fixtures = URL(fileURLWithPath: #filePath)
9
+ .deletingLastPathComponent() // VideoTranscoderCoreTests
10
+ .deletingLastPathComponent() // Tests
11
+ .deletingLastPathComponent() // ios
12
+ .deletingLastPathComponent() // the repository
13
+ .appendingPathComponent("test/fixtures")
14
+
15
+ var outputs: URL!
16
+
17
+ override func setUpWithError() throws {
18
+ outputs = FileManager.default.temporaryDirectory.appendingPathComponent("video-transcoder-tests-\(UUID().uuidString)")
19
+ try FileManager.default.createDirectory(at: outputs, withIntermediateDirectories: true)
20
+ }
21
+
22
+ override func tearDownWithError() throws {
23
+ try? FileManager.default.removeItem(at: outputs)
24
+ }
25
+
26
+ func fixture(_ name: String) -> URL { TranscoderTests.fixtures.appendingPathComponent(name) }
27
+
28
+ func testRotatedVideoWithPCMAudio() async throws {
29
+ let out = outputs.appendingPathComponent("rotated.mp4")
30
+ let progress = Recorder()
31
+ let outcome = try await Transcoder().transcode(
32
+ TranscodeOptions(source: fixture("rotated-pcm.mov"), destination: out),
33
+ progress: { progress.add($0) }
34
+ )
35
+ XCTAssertEqual(outcome.url, out)
36
+ // stored 320x240, shown upright as 240x320
37
+ XCTAssertEqual(outcome.width, 240)
38
+ XCTAssertEqual(outcome.height, 320)
39
+ XCTAssertEqual(Double(outcome.durationMs), 2000, accuracy: 100)
40
+ XCTAssertGreaterThan(outcome.size, 0)
41
+
42
+ let facts = try await Facts.read(out)
43
+ XCTAssertEqual(facts.videoCodec, kCMVideoCodecType_H264)
44
+ XCTAssertEqual(facts.avcProfile, 100, "H.264 High")
45
+ XCTAssertEqual(facts.storedSize, CGSize(width: 320, height: 240))
46
+ // the rotation is carried over as the track transform, unchanged
47
+ let source = try await Facts.read(fixture("rotated-pcm.mov"))
48
+ XCTAssertNotEqual(Geometry.rotationDegrees(of: source.transform), 0)
49
+ XCTAssertEqual(Geometry.rotationDegrees(of: facts.transform), Geometry.rotationDegrees(of: source.transform))
50
+ XCTAssertEqual(facts.primaries, kCMFormatDescriptionColorPrimaries_ITU_R_709_2 as String)
51
+ XCTAssertEqual(facts.transfer, kCMFormatDescriptionTransferFunction_ITU_R_709_2 as String)
52
+ XCTAssertEqual(facts.matrix, kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2 as String)
53
+ XCTAssertEqual(facts.audioFormat, kAudioFormatMPEG4AAC)
54
+ XCTAssertEqual(facts.audioChannels, 2)
55
+ XCTAssertEqual(facts.audioSampleRate, 16_000)
56
+ XCTAssertEqual(facts.topLevelAtoms.filter { $0 == "moov" || $0 == "mdat" }, ["moov", "mdat"], "faststart")
57
+
58
+ let values = progress.values
59
+ XCTAssertEqual(values.last, 1)
60
+ XCTAssertEqual(values, values.sorted(), "progress only grows")
61
+ XCTAssertGreaterThan(values.count, 2)
62
+ }
63
+
64
+ func testHDRIsToneMappedAndAACIsCopied() async throws {
65
+ let out = outputs.appendingPathComponent("hdr.mp4")
66
+ let outcome = try await Transcoder().transcode(TranscodeOptions(source: fixture("hdr-hlg.mov"), destination: out))
67
+ XCTAssertEqual(outcome.width, 256)
68
+ XCTAssertEqual(outcome.height, 144)
69
+
70
+ let facts = try await Facts.read(out)
71
+ XCTAssertEqual(facts.videoCodec, kCMVideoCodecType_H264)
72
+ XCTAssertEqual(facts.primaries, kCMFormatDescriptionColorPrimaries_ITU_R_709_2 as String)
73
+ XCTAssertEqual(facts.transfer, kCMFormatDescriptionTransferFunction_ITU_R_709_2 as String)
74
+ XCTAssertEqual(facts.audioFormat, kAudioFormatMPEG4AAC)
75
+ XCTAssertEqual(facts.audioChannels, 1)
76
+ XCTAssertEqual(facts.audioSampleRate, 44_100)
77
+ }
78
+
79
+ func testScalesDownCutsAndAddsNoAudio() async throws {
80
+ let out = outputs.appendingPathComponent("nested/dir/silent.mp4")
81
+ let outcome = try await Transcoder().transcode(TranscodeOptions(
82
+ source: fixture("long-silent.mp4"), destination: out, maxSize: 320, maxDurationMs: 1500, videoBitrate: 200_000
83
+ ))
84
+ XCTAssertEqual(outcome.width, 320)
85
+ XCTAssertEqual(outcome.height, 180)
86
+ XCTAssertEqual(Double(outcome.durationMs), 1500, accuracy: 70)
87
+
88
+ let facts = try await Facts.read(out)
89
+ XCTAssertEqual(facts.storedSize, CGSize(width: 320, height: 180))
90
+ XCTAssertNil(facts.audioFormat)
91
+ }
92
+
93
+ func testReplacesAFileAtTheDestinationAndDefaultsToTheTemporaryDirectory() async throws {
94
+ let out = outputs.appendingPathComponent("taken.mp4")
95
+ try Data("not a video".utf8).write(to: out)
96
+ _ = try await Transcoder().transcode(TranscodeOptions(source: fixture("hdr-hlg.mov"), destination: out))
97
+ let replaced = try await Facts.read(out)
98
+ XCTAssertEqual(replaced.videoCodec, kCMVideoCodecType_H264)
99
+
100
+ let outcome = try await Transcoder().transcode(TranscodeOptions(source: fixture("hdr-hlg.mov")))
101
+ defer { try? FileManager.default.removeItem(at: outcome.url) }
102
+ XCTAssertTrue(outcome.url.path.hasPrefix(FileManager.default.temporaryDirectory.resolvingSymlinksInPath().path)
103
+ || outcome.url.path.hasPrefix(FileManager.default.temporaryDirectory.path))
104
+ XCTAssertEqual(outcome.url.pathExtension, "mp4")
105
+ XCTAssertTrue(FileManager.default.fileExists(atPath: outcome.url.path))
106
+ }
107
+
108
+ func testNotFoundAndNoVideo() async throws {
109
+ await assertFails(.notFound, TranscodeOptions(source: outputs.appendingPathComponent("missing.mov")))
110
+ await assertFails(.notFound, TranscodeOptions(source: outputs))
111
+ let text = outputs.appendingPathComponent("text.mov")
112
+ try Data("not a video".utf8).write(to: text)
113
+ let out = outputs.appendingPathComponent("never.mp4")
114
+ await assertFails(.noVideo, TranscodeOptions(source: text, destination: out))
115
+ XCTAssertFalse(FileManager.default.fileExists(atPath: out.path))
116
+ }
117
+
118
+ func testOneAtATimeAndCancel() async throws {
119
+ let transcoder = Transcoder()
120
+ let out = outputs.appendingPathComponent("cancelled.mp4")
121
+ let started = expectation(description: "progress")
122
+ started.assertForOverFulfill = false
123
+ let first = Task {
124
+ try await transcoder.transcode(TranscodeOptions(source: fixture("long-silent.mp4"), destination: out)) { _ in
125
+ started.fulfill()
126
+ }
127
+ }
128
+ await fulfillment(of: [started], timeout: 30)
129
+ await assertFails(.busy, TranscodeOptions(source: fixture("hdr-hlg.mov")), with: transcoder)
130
+ transcoder.cancel()
131
+ do {
132
+ _ = try await first.value
133
+ // a fast machine can finish the clip before the cancel lands; then there is nothing to check
134
+ } catch {
135
+ XCTAssertEqual((error as? TranscodeError)?.code, .cancelled)
136
+ XCTAssertFalse(FileManager.default.fileExists(atPath: out.path), "a cancelled transcode leaves no file")
137
+ }
138
+ // and the transcoder is free again
139
+ _ = try await transcoder.transcode(TranscodeOptions(source: fixture("hdr-hlg.mov"), destination: out))
140
+ transcoder.cancel() // with nothing running, nothing happens
141
+ }
142
+
143
+ func assertFails(
144
+ _ code: TranscodeErrorCode,
145
+ _ options: TranscodeOptions,
146
+ with transcoder: Transcoder = Transcoder(),
147
+ line: UInt = #line
148
+ ) async {
149
+ do {
150
+ _ = try await transcoder.transcode(options)
151
+ XCTFail("expected \(code.rawValue)", line: line)
152
+ } catch {
153
+ XCTAssertEqual((error as? TranscodeError)?.code, code, "\(error)", line: line)
154
+ }
155
+ }
156
+ }
157
+
158
+ final class Recorder: @unchecked Sendable {
159
+ private let lock = NSLock()
160
+ private var list: [Double] = []
161
+ func add(_ value: Double) {
162
+ lock.lock()
163
+ list.append(value)
164
+ lock.unlock()
165
+ }
166
+ var values: [Double] {
167
+ lock.lock()
168
+ defer { lock.unlock() }
169
+ return list
170
+ }
171
+ }
172
+
173
+ /// What a written MP4 says about itself, read with AVFoundation and from its top-level boxes.
174
+ struct Facts {
175
+ var videoCodec: FourCharCode?
176
+ var avcProfile: UInt8?
177
+ var storedSize = CGSize.zero
178
+ var transform = CGAffineTransform.identity
179
+ var primaries: String?
180
+ var transfer: String?
181
+ var matrix: String?
182
+ var audioFormat: AudioFormatID?
183
+ var audioChannels: UInt32?
184
+ var audioSampleRate: Float64?
185
+ var topLevelAtoms: [String] = []
186
+
187
+ static func read(_ url: URL) async throws -> Facts {
188
+ var facts = Facts()
189
+ let asset = AVURLAsset(url: url)
190
+ if let video = try await asset.loadTracks(withMediaType: .video).first {
191
+ facts.storedSize = try await video.load(.naturalSize)
192
+ facts.transform = try await video.load(.preferredTransform)
193
+ if let description = try await video.load(.formatDescriptions).first {
194
+ facts.videoCodec = CMFormatDescriptionGetMediaSubType(description)
195
+ let extensions = CMFormatDescriptionGetExtensions(description) as? [String: Any] ?? [:]
196
+ facts.primaries = extensions[kCMFormatDescriptionExtension_ColorPrimaries as String] as? String
197
+ facts.transfer = extensions[kCMFormatDescriptionExtension_TransferFunction as String] as? String
198
+ facts.matrix = extensions[kCMFormatDescriptionExtension_YCbCrMatrix as String] as? String
199
+ let atoms = extensions[kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms as String] as? [String: Any]
200
+ if let avcC = atoms?["avcC"] as? Data, avcC.count > 1 {
201
+ facts.avcProfile = avcC[avcC.startIndex + 1]
202
+ }
203
+ }
204
+ }
205
+ if let audio = try await asset.loadTracks(withMediaType: .audio).first,
206
+ let description = try await audio.load(.formatDescriptions).first,
207
+ let basic = CMAudioFormatDescriptionGetStreamBasicDescription(description)?.pointee {
208
+ facts.audioFormat = basic.mFormatID
209
+ facts.audioChannels = basic.mChannelsPerFrame
210
+ facts.audioSampleRate = basic.mSampleRate
211
+ }
212
+ facts.topLevelAtoms = try topLevelAtoms(url)
213
+ return facts
214
+ }
215
+
216
+ static func topLevelAtoms(_ url: URL) throws -> [String] {
217
+ let data = try Data(contentsOf: url)
218
+ var atoms: [String] = []
219
+ var offset = 0
220
+ while offset + 8 <= data.count {
221
+ var size = data[offset..<offset + 4].reduce(0) { $0 << 8 | UInt64($1) }
222
+ let type = String(decoding: data[offset + 4..<offset + 8], as: UTF8.self)
223
+ if size == 1, offset + 16 <= data.count {
224
+ size = data[offset + 8..<offset + 16].reduce(0) { $0 << 8 | UInt64($1) }
225
+ } else if size == 0 {
226
+ size = UInt64(data.count - offset)
227
+ }
228
+ atoms.append(type)
229
+ guard size >= 8 else { break }
230
+ offset += Int(size)
231
+ }
232
+ return atoms
233
+ }
234
+ }
@@ -0,0 +1,32 @@
1
+ import XCTest
2
+ @testable import VideoTranscoderPlugin
3
+ import VideoTranscoderCore
4
+
5
+ /// The bridge side of the plugin: its name and methods, and how a call's options and a finished transcode cross the bridge. The
6
+ /// transcoding itself is tested in VideoTranscoderCoreTests, which also run on a Mac without a simulator.
7
+ final class VideoTranscoderPluginTests: XCTestCase {
8
+ func testRegistersTheMethodsOfTheTypeScriptAPI() {
9
+ let plugin = VideoTranscoderPlugin()
10
+ XCTAssertEqual(plugin.jsName, "VideoTranscoder")
11
+ XCTAssertEqual(plugin.pluginMethods.map(\.name), ["transcode", "cancel"])
12
+ }
13
+
14
+ func testKeepsTheStringKeysOfTheCallOptions() {
15
+ let raw = VideoTranscoderPlugin.raw(["source": "/a.mov", "maxSize": 640, 1: "dropped"])
16
+ XCTAssertEqual(raw.count, 2)
17
+ XCTAssertEqual(try TranscodeOptions.parse(raw).maxSize, 640)
18
+ }
19
+
20
+ func testResultCarriesEveryFieldOfTheTypeScriptResult() {
21
+ let url = URL(fileURLWithPath: "/tmp/out put.mp4")
22
+ let result = VideoTranscoderPlugin.result(
23
+ TranscodeOutcome(url: url, width: 720, height: 1280, durationMs: 3000, size: 12345)
24
+ )
25
+ XCTAssertEqual(result["path"] as? String, "/tmp/out put.mp4")
26
+ XCTAssertEqual(result["uri"] as? String, "file:///tmp/out%20put.mp4")
27
+ XCTAssertEqual(result["width"] as? Int, 720)
28
+ XCTAssertEqual(result["height"] as? Int, 1280)
29
+ XCTAssertEqual(result["durationMs"] as? Int, 3000)
30
+ XCTAssertEqual(result["size"] as? Int, 12345)
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,103 @@
1
1
  {
2
2
  "name": "capacitor-video-transcoder",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Transcode a video into an MP4 that web browsers play, on iOS and Android, from a Capacitor app",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapacitorVideoTranscoder.podspec"
17
+ ],
18
+ "author": "Maxim Belov",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/MaximBelov/capacitor-video-transcoder.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/MaximBelov/capacitor-video-transcoder/issues"
26
+ },
27
+ "keywords": [
28
+ "capacitor",
29
+ "plugin",
30
+ "native",
31
+ "video",
32
+ "transcode",
33
+ "mp4",
34
+ "h264",
35
+ "compress",
36
+ "ffmpeg alternative",
37
+ "media3",
38
+ "avfoundation"
39
+ ],
40
+ "scripts": {
41
+ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
42
+ "verify:ios": "xcodebuild build -scheme CapacitorVideoTranscoder -destination generic/platform=iOS -derivedDataPath build/ios-device",
43
+ "verify:android": "cd android && ./gradlew clean build test",
44
+ "verify:web": "npm run build",
45
+ "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
46
+ "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
47
+ "eslint": "eslint . --ext ts",
48
+ "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
49
+ "swiftlint": "node-swiftlint",
50
+ "docgen": "docgen --api VideoTranscoderPlugin --output-readme README.md --output-json dist/docs.json",
51
+ "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
52
+ "clean": "rimraf ./dist",
53
+ "watch": "tsc --watch",
54
+ "prepublishOnly": "npm run build",
55
+ "test": "npm run test:unit",
56
+ "test:unit": "vitest run",
57
+ "test:unit:coverage": "vitest run --coverage",
58
+ "test:android": "cd android && ./gradlew test",
59
+ "test:core": "swift test --package-path ios",
60
+ "test:ios": "./scripts/test-ios.sh",
61
+ "fixtures": "./test/fixtures/make-fixtures.sh",
62
+ "example:install": "cd example-app && npm install --no-audit --no-fund",
63
+ "example:android:build": "npm run example:install && cd example-app && npm run build && npx cap sync android && cd android && ./gradlew assembleDebug",
64
+ "example:ios:build": "npm run example:install && cd example-app && npm run build && npx cap sync ios && xcodebuild build -project ios/App/App.xcodeproj -scheme App -destination generic/platform=iOS\\ Simulator -derivedDataPath ../build/example-ios CODE_SIGNING_ALLOWED=NO"
65
+ },
66
+ "devDependencies": {
67
+ "@capacitor/android": "^8.0.0",
68
+ "@capacitor/core": "^8.0.0",
69
+ "@capacitor/docgen": "^0.3.1",
70
+ "@capacitor/ios": "^8.0.0",
71
+ "@ionic/eslint-config": "^0.4.0",
72
+ "@ionic/prettier-config": "^4.0.0",
73
+ "@ionic/swiftlint-config": "^2.0.0",
74
+ "@vitest/coverage-v8": "^4.1.12",
75
+ "eslint": "^8.57.1",
76
+ "prettier": "^3.6.2",
77
+ "prettier-plugin-java": "^2.7.7",
78
+ "rimraf": "^6.1.0",
79
+ "rollup": "^4.53.2",
80
+ "swiftlint": "^2.0.0",
81
+ "typescript": "^5.9.3",
82
+ "vitest": "^4.1.12"
83
+ },
84
+ "peerDependencies": {
85
+ "@capacitor/core": ">=8.0.0"
86
+ },
87
+ "prettier": "@ionic/prettier-config",
88
+ "swiftlint": "@ionic/swiftlint-config",
89
+ "eslintConfig": {
90
+ "extends": "@ionic/eslint-config/recommended"
91
+ },
92
+ "capacitor": {
93
+ "ios": {
94
+ "src": "ios"
95
+ },
96
+ "android": {
97
+ "src": "android"
98
+ }
99
+ },
100
+ "engines": {
101
+ "node": ">=20"
102
+ }
103
+ }