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.
- package/CapacitorVideoTranscoder.podspec +18 -0
- package/Package.swift +39 -0
- package/README.md +238 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/TranscodeOptions.kt +122 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoder.kt +265 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoderPlugin.kt +51 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +221 -0
- package/dist/esm/definitions.d.ts +120 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +10 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +24 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +27 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Package.swift +22 -0
- package/ios/Sources/VideoTranscoderCore/Geometry.swift +61 -0
- package/ios/Sources/VideoTranscoderCore/TranscodeOptions.swift +107 -0
- package/ios/Sources/VideoTranscoderCore/Transcoder.swift +388 -0
- package/ios/Sources/VideoTranscoderPlugin/VideoTranscoderPlugin.swift +73 -0
- package/ios/Tests/VideoTranscoderCoreTests/OptionsAndGeometryTests.swift +146 -0
- package/ios/Tests/VideoTranscoderCoreTests/TranscoderTests.swift +234 -0
- package/ios/Tests/VideoTranscoderPluginTests/VideoTranscoderPluginTests.swift +32 -0
- package/package.json +101 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'CapacitorVideoTranscoder'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.license = package['license']
|
|
10
|
+
s.homepage = package['repository']['url']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
|
|
13
|
+
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
|
|
14
|
+
s.ios.deployment_target = '15.0'
|
|
15
|
+
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo'
|
|
16
|
+
s.dependency 'Capacitor'
|
|
17
|
+
s.swift_version = '5.9'
|
|
18
|
+
end
|
package/Package.swift
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// swift-tools-version: 5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// The plugin is two modules: VideoTranscoderCore, the transcoder itself on AVFoundation alone, and VideoTranscoderPlugin, the
|
|
5
|
+
// Capacitor bridge to it. The core has a package of its own as well, ios/Package.swift, so that its tests run with `swift test`
|
|
6
|
+
// on a Mac, where the bridge (which needs UIKit through Capacitor) does not build.
|
|
7
|
+
let package = Package(
|
|
8
|
+
name: "CapacitorVideoTranscoder",
|
|
9
|
+
platforms: [.iOS(.v15)],
|
|
10
|
+
products: [
|
|
11
|
+
.library(
|
|
12
|
+
name: "CapacitorVideoTranscoder",
|
|
13
|
+
targets: ["VideoTranscoderPlugin"])
|
|
14
|
+
],
|
|
15
|
+
dependencies: [
|
|
16
|
+
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
|
17
|
+
],
|
|
18
|
+
targets: [
|
|
19
|
+
.target(
|
|
20
|
+
name: "VideoTranscoderCore",
|
|
21
|
+
path: "ios/Sources/VideoTranscoderCore"),
|
|
22
|
+
.target(
|
|
23
|
+
name: "VideoTranscoderPlugin",
|
|
24
|
+
dependencies: [
|
|
25
|
+
"VideoTranscoderCore",
|
|
26
|
+
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
27
|
+
.product(name: "Cordova", package: "capacitor-swift-pm")
|
|
28
|
+
],
|
|
29
|
+
path: "ios/Sources/VideoTranscoderPlugin"),
|
|
30
|
+
.testTarget(
|
|
31
|
+
name: "VideoTranscoderCoreTests",
|
|
32
|
+
dependencies: ["VideoTranscoderCore"],
|
|
33
|
+
path: "ios/Tests/VideoTranscoderCoreTests"),
|
|
34
|
+
.testTarget(
|
|
35
|
+
name: "VideoTranscoderPluginTests",
|
|
36
|
+
dependencies: ["VideoTranscoderPlugin", "VideoTranscoderCore"],
|
|
37
|
+
path: "ios/Tests/VideoTranscoderPluginTests")
|
|
38
|
+
]
|
|
39
|
+
)
|
package/README.md
CHANGED
|
@@ -1,3 +1,239 @@
|
|
|
1
|
-
#
|
|
1
|
+
# capacitor-video-transcoder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn a video from the phone's camera or gallery into an MP4 that every web browser plays, on iOS and Android, from a Capacitor app.
|
|
4
|
+
|
|
5
|
+
A phone records what suits the phone: HEVC, often 10-bit HDR, 4K, in a `.mov` on iOS. A browser on another computer may play none
|
|
6
|
+
of that. This plugin writes a small, plain MP4 instead, with the platform's own hardware encoder: AVFoundation on iOS, Media3
|
|
7
|
+
Transformer on Android. No FFmpeg, nothing to license, nothing large added to the app.
|
|
8
|
+
|
|
9
|
+
What comes out:
|
|
10
|
+
|
|
11
|
+
- H.264 video, High profile where the encoder offers it, 8-bit 4:2:0, tagged BT.709
|
|
12
|
+
- an HDR source (HLG or PQ) tone-mapped to SDR, so it does not look washed out
|
|
13
|
+
- AAC audio: an AAC track is copied as it is, anything else is encoded, keeping mono as mono and stereo as stereo
|
|
14
|
+
- the longer side at most `maxSize` (1280 by default), the aspect ratio kept, both sides even
|
|
15
|
+
- cut at `maxDurationMs` (30 seconds by default)
|
|
16
|
+
- `moov` before `mdat` ("faststart"), so playback starts before the whole file has downloaded
|
|
17
|
+
- a portrait video stored the way the phone stored it, with its rotation as track metadata, so players show it upright
|
|
18
|
+
- a video bitrate you choose (1.8 Mbit/s by default)
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install capacitor-video-transcoder
|
|
24
|
+
npx cap sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Capacitor 8. Android 8.0 (API 26) or newer, iOS 15 or newer. On iOS the plugin is a Swift package; CocoaPods works too.
|
|
28
|
+
Android adds [Media3 Transformer](https://developer.android.com/media/media3/transformer); iOS uses system frameworks only.
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { VideoTranscoder } from 'capacitor-video-transcoder';
|
|
34
|
+
|
|
35
|
+
const progress = await VideoTranscoder.addListener('progress', ({ progress }) => {
|
|
36
|
+
console.log(`${Math.round(progress * 100)}%`);
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
const video = await VideoTranscoder.transcode({ source: picked.path });
|
|
40
|
+
// video.path is an MP4 in the cache directory: upload it, then delete it
|
|
41
|
+
console.log(video.width, video.height, video.durationMs, video.size);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if ((error as { code?: string }).code !== 'CANCELLED') throw error;
|
|
44
|
+
} finally {
|
|
45
|
+
await progress.remove();
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`source` is an absolute path or a `file://` URL. On Android it can also be the `content://` URI a picker returns: the plugin
|
|
50
|
+
reads it in place, so there is no need to copy the video into the app first.
|
|
51
|
+
|
|
52
|
+
Stop a running transcode with `cancel()`. The `transcode` call then rejects with `CANCELLED`, and no file is left behind.
|
|
53
|
+
|
|
54
|
+
One transcode runs at a time. A second call while one is running rejects with `BUSY`.
|
|
55
|
+
|
|
56
|
+
### Replacing an FFmpeg command
|
|
57
|
+
|
|
58
|
+
The defaults match what this FFmpeg command does, which is a common way to make a phone video safe for a browser:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
ffmpeg -i in.mov -vcodec libx264 -vf scale=w=1280:h=1280:force_original_aspect_ratio=decrease \
|
|
62
|
+
-pix_fmt yuv420p -c:a aac -ss 00:00 -to 00:30 -movflags +faststart out.mp4
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The differences: this plugin also tags the colour as BT.709 and tone-maps HDR (that command leaves HDR colour values in an SDR
|
|
66
|
+
file), keeps an AAC track as it is instead of re-encoding it, and asks the encoder for an average bitrate rather than a quality
|
|
67
|
+
level, so file sizes are predictable.
|
|
68
|
+
|
|
69
|
+
## How each platform does it
|
|
70
|
+
|
|
71
|
+
**iOS.** `AVAssetReader` decodes the video into 8-bit 4:2:0 frames converted to BT.709 (this is where HDR is tone-mapped), and
|
|
72
|
+
`AVAssetWriter` encodes them with the H.264 encoder at the size and bitrate asked for, writing the source track's transform as the
|
|
73
|
+
output track's transform. An AAC audio track is passed through; any other is decoded and encoded to AAC with the source's channel
|
|
74
|
+
count (more than two channels are mixed down to stereo) and sample rate. `shouldOptimizeForNetworkUse` puts the `moov` box first.
|
|
75
|
+
|
|
76
|
+
**Android.** Media3 `Transformer` with H.264 and AAC as the output formats, a `Presentation` effect that scales to fit, a clipping
|
|
77
|
+
configuration for the length, `HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL` for HDR, and Media3's own MP4 muxer with streamable
|
|
78
|
+
output for the `moov` box. Transformer copies an AAC track without decoding it, and picks H.264 High when the encoder has it. HDR
|
|
79
|
+
tone-mapping needs Android 10 (API 29) or newer; an HDR video on an older phone is rare, and fails with `FAILED`.
|
|
80
|
+
|
|
81
|
+
<docgen-index>
|
|
82
|
+
|
|
83
|
+
* [`transcode(...)`](#transcode)
|
|
84
|
+
* [`cancel()`](#cancel)
|
|
85
|
+
* [`addListener('progress', ...)`](#addlistenerprogress-)
|
|
86
|
+
* [`removeAllListeners()`](#removealllisteners)
|
|
87
|
+
* [Interfaces](#interfaces)
|
|
88
|
+
|
|
89
|
+
</docgen-index>
|
|
90
|
+
|
|
91
|
+
<docgen-api>
|
|
92
|
+
<!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
|
|
93
|
+
|
|
94
|
+
### transcode(...)
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
transcode(options: TranscodeOptions) => Promise<TranscodeResult>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Transcode a video into an MP4 that web browsers play:
|
|
101
|
+
|
|
102
|
+
- H.264 video (High profile where the encoder offers it), 8-bit 4:2:0, tagged
|
|
103
|
+
BT.709; an HDR source (HLG or PQ) is tone-mapped to SDR
|
|
104
|
+
- AAC audio: an AAC source track is copied as it is, anything else is
|
|
105
|
+
encoded; a video without sound gets no audio track
|
|
106
|
+
- the longer side at most `maxSize`, cut at `maxDurationMs`
|
|
107
|
+
- the index (`moov`) before the media data, so playback can start before
|
|
108
|
+
the whole file has downloaded
|
|
109
|
+
- a portrait video keeps its rotation as track metadata, the way a phone
|
|
110
|
+
records it, so players show it upright
|
|
111
|
+
|
|
112
|
+
One runs at a time: a second call while one is running rejects with `BUSY`.
|
|
113
|
+
On the web it rejects with the `UNIMPLEMENTED` code.
|
|
114
|
+
|
|
115
|
+
| Param | Type |
|
|
116
|
+
| ------------- | ------------------------------------------------------------- |
|
|
117
|
+
| **`options`** | <code><a href="#transcodeoptions">TranscodeOptions</a></code> |
|
|
118
|
+
|
|
119
|
+
**Returns:** <code>Promise<<a href="#transcoderesult">TranscodeResult</a>></code>
|
|
120
|
+
|
|
121
|
+
--------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
### cancel()
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
cancel() => Promise<void>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Stop the running `transcode`, which then rejects with `CANCELLED` and leaves
|
|
131
|
+
no file behind. Resolves straight away, and does nothing when none is
|
|
132
|
+
running. On the web it rejects with the `UNIMPLEMENTED` code.
|
|
133
|
+
|
|
134
|
+
--------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### addListener('progress', ...)
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
addListener(eventName: 'progress', listenerFunc: (event: TranscodeProgress) => void) => Promise<PluginListenerHandle>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Listen to the progress of the running `transcode`.
|
|
144
|
+
|
|
145
|
+
| Param | Type |
|
|
146
|
+
| ------------------ | ----------------------------------------------------------------------------------- |
|
|
147
|
+
| **`eventName`** | <code>'progress'</code> |
|
|
148
|
+
| **`listenerFunc`** | <code>(event: <a href="#transcodeprogress">TranscodeProgress</a>) => void</code> |
|
|
149
|
+
|
|
150
|
+
**Returns:** <code>Promise<<a href="#pluginlistenerhandle">PluginListenerHandle</a>></code>
|
|
151
|
+
|
|
152
|
+
--------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
### removeAllListeners()
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
removeAllListeners() => Promise<void>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Remove every listener of this plugin.
|
|
162
|
+
|
|
163
|
+
--------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
### Interfaces
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#### TranscodeResult
|
|
170
|
+
|
|
171
|
+
| Prop | Type | Description |
|
|
172
|
+
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
173
|
+
| **`path`** | <code>string</code> | The absolute path of the MP4 that was written. |
|
|
174
|
+
| **`uri`** | <code>string</code> | The same file as a `file://` URL. |
|
|
175
|
+
| **`width`** | <code>number</code> | The width of the video as a player shows it, upright: the rotation the output carries as metadata is already applied. |
|
|
176
|
+
| **`height`** | <code>number</code> | The height of the video as a player shows it, upright. |
|
|
177
|
+
| **`durationMs`** | <code>number</code> | The length of the output, in milliseconds. |
|
|
178
|
+
| **`size`** | <code>number</code> | The size of the output file, in bytes. |
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
#### TranscodeOptions
|
|
182
|
+
|
|
183
|
+
| Prop | Type | Description | Default |
|
|
184
|
+
| ------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
|
185
|
+
| **`source`** | <code>string</code> | The video to transcode: an absolute path or a `file://` URL. On Android it can also be a `content://` URI, such as the one a system picker returns; it is read in place, without a copy. | |
|
|
186
|
+
| **`destination`** | <code>string</code> | Where to write the MP4: an absolute path or a `file://` URL. A file that is already there is replaced, and missing directories are created. When it is left out, a new file is created in the app's cache directory (Android) or temporary directory (iOS). The app owns that file and deletes it when it is done with it. | |
|
|
187
|
+
| **`maxSize`** | <code>number</code> | The longest side of the output, in pixels. A larger video is scaled down to fit, keeping its aspect ratio; a smaller one keeps its size. Both sides are rounded down to an even number, as H.264 4:2:0 needs. A whole number, at least 16. | <code>1280</code> |
|
|
188
|
+
| **`maxDurationMs`** | <code>number</code> | The longest the output may be, in milliseconds. A longer video is cut at this point; a shorter one is kept whole. A whole number, at least 1. | <code>30000</code> |
|
|
189
|
+
| **`videoBitrate`** | <code>number</code> | The average video bitrate the encoder is asked for, in bits per second. A whole number, at least 1. | <code>1800000</code> |
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
#### PluginListenerHandle
|
|
193
|
+
|
|
194
|
+
| Prop | Type |
|
|
195
|
+
| ------------ | ----------------------------------------- |
|
|
196
|
+
| **`remove`** | <code>() => Promise<void></code> |
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
#### TranscodeProgress
|
|
200
|
+
|
|
201
|
+
| Prop | Type | Description |
|
|
202
|
+
| -------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
203
|
+
| **`progress`** | <code>number</code> | How far the running `transcode` is, from 0 to 1. It only grows, and the last event before the promise resolves is 1. |
|
|
204
|
+
|
|
205
|
+
</docgen-api>
|
|
206
|
+
|
|
207
|
+
## Checked on phones
|
|
208
|
+
|
|
209
|
+
The example app run on a Pixel 10a (Android 16) and an iPhone 16 (iOS 27), on three phone recordings and the three test clips, with
|
|
210
|
+
every output read back with `ffprobe`: H.264 High, `yuv420p`, BT.709 primaries, transfer and matrix, AAC LC, `moov` before `mdat`,
|
|
211
|
+
the rotation of the source kept, and the size, length and bitrate asked for.
|
|
212
|
+
|
|
213
|
+
| Source | Android output | iOS output |
|
|
214
|
+
| --- | --- | --- |
|
|
215
|
+
| iPhone HEVC 10-bit HLG, 1920x1080 rotated, 35 s, AAC stereo | 1280x720 rotated (720x1280 shown), 30.0 s, 7.7 MB, 3.5 s | 1280x720 rotated, 30.0 s, 7.2 MB, 2.8 s |
|
|
216
|
+
| Android HEVC, 1920x1080 rotated, 12 s, AAC stereo | 1280x720 rotated, 12.0 s, 3.3 MB, 1.7 s | 1280x720 rotated, 12.0 s, 2.9 MB, 1.1 s |
|
|
217
|
+
| H.264 1920x1080, 10 s, AAC mono | 1280x720, 10.0 s, 2.7 MB, 1.5 s | 1280x720, 10.0 s, 2.3 MB, 0.9 s |
|
|
218
|
+
| PCM stereo 16 kHz (test clip) | AAC stereo 16 kHz | AAC stereo 16 kHz |
|
|
219
|
+
| no audio (test clip) | no audio track | no audio track |
|
|
220
|
+
|
|
221
|
+
Times are the whole call, as JavaScript saw it. Cancelling, `BUSY`, a `content://` URI on Android, the default destination and the
|
|
222
|
+
error codes were checked in the same runs.
|
|
223
|
+
|
|
224
|
+
## Tests
|
|
225
|
+
|
|
226
|
+
| What | Where it runs | Command |
|
|
227
|
+
| --- | --- | --- |
|
|
228
|
+
| The TypeScript: the bridge calls, the web fallback, and that the API, both platforms and this README agree | Node | `npm test` |
|
|
229
|
+
| The option parsing and size arithmetic of the Kotlin | JVM (no device) | `npm run test:android` |
|
|
230
|
+
| The Swift core: option parsing, size arithmetic, and end-to-end transcodes of the clips in `test/fixtures` read back with AVFoundation | a Mac, `swift test` (no simulator) | `npm run test:core` |
|
|
231
|
+
| The same Swift tests and the bridge's own tests | an iOS simulator | `npm run test:ios` |
|
|
232
|
+
| The example app, built with the plugin installed | Gradle, Xcode | `npm run example:android:build`, `npm run example:ios:build` |
|
|
233
|
+
|
|
234
|
+
`test/fixtures/make-fixtures.sh` writes the test clips with FFmpeg. The example app in `example-app/` transcodes every video it finds
|
|
235
|
+
in its `in/` folder when it starts, and writes the results to `out/`: it is how the plugin is checked on a phone.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
ext {
|
|
2
|
+
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
|
3
|
+
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
|
|
4
|
+
androidxCoreKTXVersion = project.hasProperty('androidxCoreKTXVersion') ? rootProject.ext.androidxCoreKTXVersion : '1.17.0'
|
|
5
|
+
// Media3 Transformer, pinned exactly: scripts/check-pins.mjs reads this line and moves it when a release is out.
|
|
6
|
+
media3Version = project.hasProperty('media3Version') ? rootProject.ext.media3Version : '1.11.1'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
buildscript {
|
|
10
|
+
ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '2.2.20'
|
|
11
|
+
repositories {
|
|
12
|
+
google()
|
|
13
|
+
mavenCentral()
|
|
14
|
+
}
|
|
15
|
+
dependencies {
|
|
16
|
+
classpath 'com.android.tools.build:gradle:8.13.0'
|
|
17
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
apply plugin: 'com.android.library'
|
|
22
|
+
apply plugin: 'kotlin-android'
|
|
23
|
+
|
|
24
|
+
android {
|
|
25
|
+
namespace = "io.github.maximbelov.videotranscoder"
|
|
26
|
+
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
|
27
|
+
defaultConfig {
|
|
28
|
+
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 26
|
|
29
|
+
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
30
|
+
versionCode 1
|
|
31
|
+
versionName "1.0"
|
|
32
|
+
}
|
|
33
|
+
buildTypes {
|
|
34
|
+
release {
|
|
35
|
+
minifyEnabled false
|
|
36
|
+
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
lintOptions {
|
|
40
|
+
abortOnError = false
|
|
41
|
+
}
|
|
42
|
+
compileOptions {
|
|
43
|
+
sourceCompatibility JavaVersion.VERSION_21
|
|
44
|
+
targetCompatibility JavaVersion.VERSION_21
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
kotlin {
|
|
49
|
+
jvmToolchain(21)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
repositories {
|
|
53
|
+
google()
|
|
54
|
+
mavenCentral()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
dependencies {
|
|
59
|
+
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
60
|
+
implementation project(':capacitor-android')
|
|
61
|
+
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
62
|
+
implementation "androidx.core:core-ktx:$androidxCoreKTXVersion"
|
|
63
|
+
implementation "androidx.media3:media3-transformer:$media3Version"
|
|
64
|
+
implementation "androidx.media3:media3-effect:$media3Version"
|
|
65
|
+
implementation "androidx.media3:media3-common:$media3Version"
|
|
66
|
+
implementation "androidx.media3:media3-muxer:$media3Version"
|
|
67
|
+
testImplementation "junit:junit:$junitVersion"
|
|
68
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
package io.github.maximbelov.videotranscoder
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
import java.net.URISyntaxException
|
|
5
|
+
|
|
6
|
+
/** The `code` a call is rejected with: the names are the ones `src/definitions.ts` documents. */
|
|
7
|
+
enum class ErrorCode { INVALID_ARGUMENT, NOT_FOUND, NO_VIDEO, BUSY, CANCELLED, FAILED }
|
|
8
|
+
|
|
9
|
+
class TranscodeException(val code: ErrorCode, message: String) : Exception(message)
|
|
10
|
+
|
|
11
|
+
/** Where the source video is: a file, or a `content://` URI read through the content resolver. */
|
|
12
|
+
sealed class SourceLocation {
|
|
13
|
+
data class File(val path: String) : SourceLocation()
|
|
14
|
+
|
|
15
|
+
data class Content(val uri: String) : SourceLocation()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a `transcode` call asked for, checked and with the defaults filled in. Pure Kotlin, so it is tested on the JVM. */
|
|
19
|
+
data class TranscodeOptions(
|
|
20
|
+
val source: SourceLocation,
|
|
21
|
+
/** An absolute path, or `null` when the call named none: a new file in the cache directory is used. */
|
|
22
|
+
val destination: String? = null,
|
|
23
|
+
val maxSize: Int = DEFAULT_MAX_SIZE,
|
|
24
|
+
val maxDurationMs: Long = DEFAULT_MAX_DURATION_MS,
|
|
25
|
+
val videoBitrate: Int = DEFAULT_VIDEO_BITRATE,
|
|
26
|
+
) {
|
|
27
|
+
companion object {
|
|
28
|
+
const val DEFAULT_MAX_SIZE = 1280
|
|
29
|
+
const val DEFAULT_MAX_DURATION_MS = 30_000L
|
|
30
|
+
const val DEFAULT_VIDEO_BITRATE = 1_800_000
|
|
31
|
+
const val MINIMUM_MAX_SIZE = 16
|
|
32
|
+
|
|
33
|
+
/** The options of a call, read one key at a time through [get], which answers `null` for a key that is absent or null. */
|
|
34
|
+
fun parse(get: (String) -> Any?): TranscodeOptions {
|
|
35
|
+
val source = get("source") as? String
|
|
36
|
+
if (source.isNullOrEmpty()) {
|
|
37
|
+
throw invalid("source is required: an absolute path, a file:// URL or a content:// URI")
|
|
38
|
+
}
|
|
39
|
+
val destination =
|
|
40
|
+
when (val value = get("destination")) {
|
|
41
|
+
null -> null
|
|
42
|
+
is String -> filePath(value, "destination")
|
|
43
|
+
else -> throw invalid("destination must be a string")
|
|
44
|
+
}
|
|
45
|
+
return TranscodeOptions(
|
|
46
|
+
source = sourceLocation(source),
|
|
47
|
+
destination = destination,
|
|
48
|
+
maxSize = wholeNumber(get("maxSize"), "maxSize", MINIMUM_MAX_SIZE, DEFAULT_MAX_SIZE),
|
|
49
|
+
maxDurationMs = wholeNumber(get("maxDurationMs"), "maxDurationMs", 1, DEFAULT_MAX_DURATION_MS.toInt()).toLong(),
|
|
50
|
+
videoBitrate = wholeNumber(get("videoBitrate"), "videoBitrate", 1, DEFAULT_VIDEO_BITRATE),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fun sourceLocation(value: String): SourceLocation =
|
|
55
|
+
if (value.startsWith("content://")) {
|
|
56
|
+
if (value.length <= "content://".length) throw invalid("source is not a content:// URI: $value")
|
|
57
|
+
SourceLocation.Content(value)
|
|
58
|
+
} else {
|
|
59
|
+
SourceLocation.File(filePath(value, "source"))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An absolute path or a `file://` URL as an absolute path. Anything else (a relative path, `http://`) is refused. */
|
|
63
|
+
fun filePath(value: String, name: String): String {
|
|
64
|
+
var path = value
|
|
65
|
+
if (value.startsWith("file://")) {
|
|
66
|
+
path =
|
|
67
|
+
try {
|
|
68
|
+
URI(value).path
|
|
69
|
+
} catch (e: URISyntaxException) {
|
|
70
|
+
null
|
|
71
|
+
} ?: value.removePrefix("file://").removePrefix("localhost")
|
|
72
|
+
}
|
|
73
|
+
if (!path.startsWith("/") || path.length < 2) {
|
|
74
|
+
throw invalid("$name must be an absolute path or a file:// URL, not $value")
|
|
75
|
+
}
|
|
76
|
+
return path
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A file as a `file:///` URL, with the escapes a URL needs. */
|
|
80
|
+
fun fileUrl(file: java.io.File): String = file.absoluteFile.toURI().toString().replaceFirst("file:/", "file:///")
|
|
81
|
+
|
|
82
|
+
private fun wholeNumber(value: Any?, name: String, minimum: Int, fallback: Int): Int {
|
|
83
|
+
if (value == null) return fallback
|
|
84
|
+
if (value !is Number) throw invalid("$name must be a number")
|
|
85
|
+
val double = value.toDouble()
|
|
86
|
+
if (double.isNaN() || double.isInfinite() || Math.rint(double) != double || double > Int.MAX_VALUE) {
|
|
87
|
+
throw invalid("$name must be a whole number, not $value")
|
|
88
|
+
}
|
|
89
|
+
if (double < minimum) throw invalid("$name must be at least $minimum, not $value")
|
|
90
|
+
return double.toInt()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fun invalid(message: String) = TranscodeException(ErrorCode.INVALID_ARGUMENT, message)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The arithmetic of the output. Pure, so it is tested without a video. */
|
|
98
|
+
object Geometry {
|
|
99
|
+
/**
|
|
100
|
+
* The size that fits a video into [maxSize] on its longer side, keeping the aspect ratio and never scaling up. Both sides are
|
|
101
|
+
* rounded down to an even number (H.264 4:2:0 halves them for the chroma planes), and are at least 2. Integer arithmetic, so
|
|
102
|
+
* 1920x1080 into 1280 is exactly 1280x720.
|
|
103
|
+
*/
|
|
104
|
+
fun outputSize(width: Int, height: Int, maxSize: Int): Pair<Int, Int> {
|
|
105
|
+
val w = maxOf(Math.abs(width), 1).toLong()
|
|
106
|
+
val h = maxOf(Math.abs(height), 1).toLong()
|
|
107
|
+
val longer = maxOf(w, h)
|
|
108
|
+
val limit = maxOf(maxSize, 2).toLong()
|
|
109
|
+
fun fit(side: Long): Int {
|
|
110
|
+
val scaled = if (longer > limit) side * limit / longer else side
|
|
111
|
+
return maxOf(2L, scaled / 2 * 2).toInt()
|
|
112
|
+
}
|
|
113
|
+
return fit(w) to fit(h)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A stored size as a player shows it, with the rotation applied. */
|
|
117
|
+
fun displayedSize(width: Int, height: Int, rotationDegrees: Int): Pair<Int, Int> =
|
|
118
|
+
if (Math.floorMod(rotationDegrees, 180) == 0) width to height else height to width
|
|
119
|
+
|
|
120
|
+
/** A percentage from Media3 as progress from 0 to 1, held below 1 until the file is finished. */
|
|
121
|
+
fun progress(percent: Int): Double = (percent.coerceIn(0, 100) / 100.0).coerceAtMost(0.99)
|
|
122
|
+
}
|