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,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
- # Temporary Holding Version
1
+ # capacitor-video-transcoder
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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&lt;<a href="#transcoderesult">TranscodeResult</a>&gt;</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>) =&gt; void</code> |
149
+
150
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</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>() =&gt; Promise&lt;void&gt;</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,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -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
+ }