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,265 @@
|
|
|
1
|
+
package io.github.maximbelov.videotranscoder
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.MediaMetadataRetriever
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import androidx.media3.common.MediaItem
|
|
9
|
+
import androidx.media3.common.MimeTypes
|
|
10
|
+
import androidx.media3.effect.Presentation
|
|
11
|
+
import androidx.media3.transformer.Composition
|
|
12
|
+
import androidx.media3.transformer.DefaultEncoderFactory
|
|
13
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
14
|
+
import androidx.media3.transformer.EditedMediaItemSequence
|
|
15
|
+
import androidx.media3.transformer.Effects
|
|
16
|
+
import androidx.media3.transformer.ExportException
|
|
17
|
+
import androidx.media3.transformer.ExportResult
|
|
18
|
+
import androidx.media3.transformer.InAppMp4Muxer
|
|
19
|
+
import androidx.media3.transformer.ProgressHolder
|
|
20
|
+
import androidx.media3.transformer.Transformer
|
|
21
|
+
import androidx.media3.transformer.VideoEncoderSettings
|
|
22
|
+
import java.io.File
|
|
23
|
+
import java.util.UUID
|
|
24
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
25
|
+
|
|
26
|
+
/** What a finished transcode wrote. [width] and [height] are the size a player shows, upright. */
|
|
27
|
+
data class TranscodeOutcome(val file: File, val width: Int, val height: Int, val durationMs: Long, val size: Long)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Transcodes a video into an MP4 a web browser plays, with Media3 Transformer: H.264 (High where the encoder has it, which
|
|
31
|
+
* Media3 picks on its own), 8-bit 4:2:0 BT.709 with HDR tone-mapped to SDR in OpenGL, AAC (an AAC source is copied, anything else
|
|
32
|
+
* encoded), the longer side at most `maxSize`, cut at `maxDurationMs`, `moov` before `mdat`, and the rotation kept as metadata.
|
|
33
|
+
* One transcode at a time; Transformer runs on the main looper.
|
|
34
|
+
*/
|
|
35
|
+
class VideoTranscoder(private val context: Context) {
|
|
36
|
+
private val main = Handler(Looper.getMainLooper())
|
|
37
|
+
private val running = AtomicBoolean(false)
|
|
38
|
+
|
|
39
|
+
@Volatile private var cancelRequested = false
|
|
40
|
+
|
|
41
|
+
/** Touched on the main thread only. */
|
|
42
|
+
private var job: Job? = null
|
|
43
|
+
|
|
44
|
+
private inner class Job(
|
|
45
|
+
val transformer: Transformer,
|
|
46
|
+
val output: File,
|
|
47
|
+
val onProgress: (Double) -> Unit,
|
|
48
|
+
val onDone: (Result<TranscodeOutcome>) -> Unit,
|
|
49
|
+
) {
|
|
50
|
+
var finished = false
|
|
51
|
+
var reported = -1.0
|
|
52
|
+
val holder = ProgressHolder()
|
|
53
|
+
val poll =
|
|
54
|
+
object : Runnable {
|
|
55
|
+
override fun run() {
|
|
56
|
+
if (finished) return
|
|
57
|
+
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
|
|
58
|
+
val value = Geometry.progress(holder.progress)
|
|
59
|
+
if (value >= reported + 0.01) {
|
|
60
|
+
reported = value
|
|
61
|
+
onProgress(value)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
main.postDelayed(this, PROGRESS_INTERVAL_MS)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun finish(result: Result<TranscodeOutcome>) {
|
|
69
|
+
if (finished) return
|
|
70
|
+
finished = true
|
|
71
|
+
main.removeCallbacks(poll)
|
|
72
|
+
if (result.isFailure) output.delete()
|
|
73
|
+
job = null
|
|
74
|
+
running.set(false)
|
|
75
|
+
onDone(result)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Starts a transcode; [onDone] is called once, on the main thread or on the calling one. */
|
|
80
|
+
fun transcode(options: TranscodeOptions, onProgress: (Double) -> Unit, onDone: (Result<TranscodeOutcome>) -> Unit) {
|
|
81
|
+
if (!running.compareAndSet(false, true)) {
|
|
82
|
+
onDone(Result.failure(TranscodeException(ErrorCode.BUSY, "another transcode is still running")))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
cancelRequested = false
|
|
86
|
+
val output: File
|
|
87
|
+
val uri: Uri
|
|
88
|
+
val target: Pair<Int, Int>
|
|
89
|
+
val hasAudio: Boolean
|
|
90
|
+
try {
|
|
91
|
+
uri = openable(options.source)
|
|
92
|
+
val source = probe(uri)
|
|
93
|
+
hasAudio = source.hasAudio
|
|
94
|
+
val shown = Geometry.displayedSize(source.width, source.height, source.rotation)
|
|
95
|
+
// Transformer turns the frames upright before the effects, so the size is fitted to the size as shown; it then
|
|
96
|
+
// encodes them in the orientation the encoder prefers and writes the rotation back as metadata.
|
|
97
|
+
target = Geometry.outputSize(shown.first, shown.second, options.maxSize)
|
|
98
|
+
output =
|
|
99
|
+
options.destination?.let { File(it) }
|
|
100
|
+
?: File(File(context.cacheDir, "video-transcoder"), "${UUID.randomUUID()}.mp4")
|
|
101
|
+
output.parentFile?.mkdirs()
|
|
102
|
+
if (output.exists() && !output.delete()) {
|
|
103
|
+
throw TranscodeException(ErrorCode.FAILED, "the file at ${output.path} cannot be replaced")
|
|
104
|
+
}
|
|
105
|
+
} catch (e: TranscodeException) {
|
|
106
|
+
running.set(false)
|
|
107
|
+
onDone(Result.failure(e))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
val item =
|
|
112
|
+
MediaItem.Builder()
|
|
113
|
+
.setUri(uri)
|
|
114
|
+
.setClippingConfiguration(
|
|
115
|
+
MediaItem.ClippingConfiguration.Builder().setEndPositionMs(options.maxDurationMs).build(),
|
|
116
|
+
)
|
|
117
|
+
.build()
|
|
118
|
+
val edited =
|
|
119
|
+
EditedMediaItem.Builder(item)
|
|
120
|
+
.setEffects(
|
|
121
|
+
Effects(
|
|
122
|
+
listOf(),
|
|
123
|
+
listOf(Presentation.createForWidthAndHeight(target.first, target.second, Presentation.LAYOUT_SCALE_TO_FIT)),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
.build()
|
|
127
|
+
// A sequence that has audio makes an audio track even when the source has none (silence, measured on a Pixel 10a), so a
|
|
128
|
+
// video without sound gets a video-only sequence.
|
|
129
|
+
val sequence =
|
|
130
|
+
if (hasAudio) EditedMediaItemSequence.withAudioAndVideoFrom(listOf(edited)) else EditedMediaItemSequence.withVideoFrom(listOf(edited))
|
|
131
|
+
val composition =
|
|
132
|
+
Composition.Builder(sequence)
|
|
133
|
+
.setHdrMode(Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL)
|
|
134
|
+
.build()
|
|
135
|
+
|
|
136
|
+
main.post {
|
|
137
|
+
if (cancelRequested) {
|
|
138
|
+
output.delete()
|
|
139
|
+
running.set(false)
|
|
140
|
+
onDone(Result.failure(cancelled()))
|
|
141
|
+
return@post
|
|
142
|
+
}
|
|
143
|
+
var current: Job? = null
|
|
144
|
+
val transformer =
|
|
145
|
+
Transformer.Builder(context)
|
|
146
|
+
.setEncoderFactory(
|
|
147
|
+
DefaultEncoderFactory.Builder(context)
|
|
148
|
+
.setRequestedVideoEncoderSettings(VideoEncoderSettings.Builder().setBitrate(options.videoBitrate).build())
|
|
149
|
+
.build(),
|
|
150
|
+
)
|
|
151
|
+
.setVideoMimeType(MimeTypes.VIDEO_H264)
|
|
152
|
+
.setAudioMimeType(MimeTypes.AUDIO_AAC)
|
|
153
|
+
.setMuxerFactory(InAppMp4Muxer.Factory().setAttemptStreamableOutputEnabled(true))
|
|
154
|
+
.addListener(
|
|
155
|
+
object : Transformer.Listener {
|
|
156
|
+
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
157
|
+
val active = current ?: return
|
|
158
|
+
val outcome = runCatching { describe(output) }
|
|
159
|
+
if (outcome.isSuccess) active.onProgress(1.0)
|
|
160
|
+
active.finish(outcome)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override fun onError(composition: Composition, exportResult: ExportResult, exception: ExportException) {
|
|
164
|
+
current?.finish(
|
|
165
|
+
Result.failure(
|
|
166
|
+
TranscodeException(ErrorCode.FAILED, "${exception.errorCodeName}: ${exception.message}"),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
.build()
|
|
173
|
+
current = Job(transformer, output, onProgress, onDone)
|
|
174
|
+
job = current
|
|
175
|
+
try {
|
|
176
|
+
transformer.start(composition, output.path)
|
|
177
|
+
main.post(current.poll)
|
|
178
|
+
} catch (e: RuntimeException) {
|
|
179
|
+
current.finish(Result.failure(TranscodeException(ErrorCode.FAILED, e.message ?: e.toString())))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Stops the running transcode, which then fails with `CANCELLED`. Does nothing when none is running. */
|
|
185
|
+
fun cancel() {
|
|
186
|
+
if (!running.get()) return
|
|
187
|
+
cancelRequested = true
|
|
188
|
+
main.post {
|
|
189
|
+
val current = job ?: return@post
|
|
190
|
+
current.transformer.cancel()
|
|
191
|
+
current.finish(Result.failure(cancelled()))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun cancelled() = TranscodeException(ErrorCode.CANCELLED, "the transcode was cancelled")
|
|
196
|
+
|
|
197
|
+
/** The source as a URI Media3 reads, once it is known to open. */
|
|
198
|
+
private fun openable(source: SourceLocation): Uri =
|
|
199
|
+
when (source) {
|
|
200
|
+
is SourceLocation.File -> {
|
|
201
|
+
val file = File(source.path)
|
|
202
|
+
if (!file.isFile || !file.canRead()) {
|
|
203
|
+
throw TranscodeException(ErrorCode.NOT_FOUND, "there is no readable file at ${source.path}")
|
|
204
|
+
}
|
|
205
|
+
Uri.fromFile(file)
|
|
206
|
+
}
|
|
207
|
+
is SourceLocation.Content -> {
|
|
208
|
+
val uri = Uri.parse(source.uri)
|
|
209
|
+
try {
|
|
210
|
+
context.contentResolver.openFileDescriptor(uri, "r")?.close()
|
|
211
|
+
?: throw TranscodeException(ErrorCode.NOT_FOUND, "${source.uri} cannot be opened")
|
|
212
|
+
} catch (e: TranscodeException) {
|
|
213
|
+
throw e
|
|
214
|
+
} catch (e: Exception) {
|
|
215
|
+
throw TranscodeException(ErrorCode.NOT_FOUND, "${source.uri} cannot be opened: ${e.message}")
|
|
216
|
+
}
|
|
217
|
+
uri
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private data class Source(val width: Int, val height: Int, val rotation: Int, val hasAudio: Boolean)
|
|
222
|
+
|
|
223
|
+
/** The stored width, height and rotation of the video track, and whether there is sound. */
|
|
224
|
+
private fun probe(uri: Uri): Source {
|
|
225
|
+
val retriever = MediaMetadataRetriever()
|
|
226
|
+
try {
|
|
227
|
+
try {
|
|
228
|
+
retriever.setDataSource(context, uri)
|
|
229
|
+
} catch (e: Exception) {
|
|
230
|
+
throw TranscodeException(ErrorCode.NO_VIDEO, "$uri is not a video this device reads: ${e.message}")
|
|
231
|
+
}
|
|
232
|
+
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) != "yes") {
|
|
233
|
+
throw TranscodeException(ErrorCode.NO_VIDEO, "$uri has no video track")
|
|
234
|
+
}
|
|
235
|
+
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
|
236
|
+
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
|
237
|
+
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
238
|
+
if (width <= 0 || height <= 0) throw TranscodeException(ErrorCode.NO_VIDEO, "$uri has a video track without a size")
|
|
239
|
+
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes"
|
|
240
|
+
return Source(width, height, rotation, hasAudio)
|
|
241
|
+
} finally {
|
|
242
|
+
retriever.release()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What was written, read back from the file. */
|
|
247
|
+
private fun describe(file: File): TranscodeOutcome {
|
|
248
|
+
val retriever = MediaMetadataRetriever()
|
|
249
|
+
try {
|
|
250
|
+
retriever.setDataSource(file.path)
|
|
251
|
+
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
|
252
|
+
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
|
253
|
+
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
254
|
+
val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0
|
|
255
|
+
val shown = Geometry.displayedSize(width, height, rotation)
|
|
256
|
+
return TranscodeOutcome(file, shown.first, shown.second, duration, file.length())
|
|
257
|
+
} finally {
|
|
258
|
+
retriever.release()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private companion object {
|
|
263
|
+
const val PROGRESS_INTERVAL_MS = 200L
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package io.github.maximbelov.videotranscoder
|
|
2
|
+
|
|
3
|
+
import com.getcapacitor.JSObject
|
|
4
|
+
import com.getcapacitor.Plugin
|
|
5
|
+
import com.getcapacitor.PluginCall
|
|
6
|
+
import com.getcapacitor.PluginMethod
|
|
7
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
@CapacitorPlugin(name = "VideoTranscoder")
|
|
11
|
+
class VideoTranscoderPlugin : Plugin() {
|
|
12
|
+
private val transcoder by lazy { VideoTranscoder(context) }
|
|
13
|
+
|
|
14
|
+
@PluginMethod
|
|
15
|
+
fun transcode(call: PluginCall) {
|
|
16
|
+
val options =
|
|
17
|
+
try {
|
|
18
|
+
TranscodeOptions.parse { key -> call.data.opt(key).takeUnless { it == JSONObject.NULL } }
|
|
19
|
+
} catch (e: TranscodeException) {
|
|
20
|
+
call.reject(e.message, e.code.name)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
transcoder.transcode(
|
|
24
|
+
options,
|
|
25
|
+
onProgress = { progress -> notifyListeners("progress", JSObject().put("progress", progress)) },
|
|
26
|
+
) { result ->
|
|
27
|
+
result.fold(
|
|
28
|
+
onSuccess = { outcome -> call.resolve(result(outcome)) },
|
|
29
|
+
onFailure = { error ->
|
|
30
|
+
val code = (error as? TranscodeException)?.code ?: ErrorCode.FAILED
|
|
31
|
+
call.reject(error.message ?: code.name, code.name)
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@PluginMethod
|
|
38
|
+
fun cancel(call: PluginCall) {
|
|
39
|
+
transcoder.cancel()
|
|
40
|
+
call.resolve()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun result(outcome: TranscodeOutcome) =
|
|
44
|
+
JSObject()
|
|
45
|
+
.put("path", outcome.file.absolutePath)
|
|
46
|
+
.put("uri", TranscodeOptions.fileUrl(outcome.file))
|
|
47
|
+
.put("width", outcome.width)
|
|
48
|
+
.put("height", outcome.height)
|
|
49
|
+
.put("durationMs", outcome.durationMs)
|
|
50
|
+
.put("size", outcome.size)
|
|
51
|
+
}
|
|
File without changes
|
package/dist/docs.json
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
{
|
|
2
|
+
"api": {
|
|
3
|
+
"name": "VideoTranscoderPlugin",
|
|
4
|
+
"slug": "videotranscoderplugin",
|
|
5
|
+
"docs": "",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"methods": [
|
|
8
|
+
{
|
|
9
|
+
"name": "transcode",
|
|
10
|
+
"signature": "(options: TranscodeOptions) => Promise<TranscodeResult>",
|
|
11
|
+
"parameters": [
|
|
12
|
+
{
|
|
13
|
+
"name": "options",
|
|
14
|
+
"docs": "",
|
|
15
|
+
"type": "TranscodeOptions"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"returns": "Promise<TranscodeResult>",
|
|
19
|
+
"tags": [],
|
|
20
|
+
"docs": "Transcode a video into an MP4 that web browsers play:\n\n- H.264 video (High profile where the encoder offers it), 8-bit 4:2:0, tagged\n BT.709; an HDR source (HLG or PQ) is tone-mapped to SDR\n- AAC audio: an AAC source track is copied as it is, anything else is\n encoded; a video without sound gets no audio track\n- the longer side at most `maxSize`, cut at `maxDurationMs`\n- the index (`moov`) before the media data, so playback can start before\n the whole file has downloaded\n- a portrait video keeps its rotation as track metadata, the way a phone\n records it, so players show it upright\n\nOne runs at a time: a second call while one is running rejects with `BUSY`.\nOn the web it rejects with the `UNIMPLEMENTED` code.",
|
|
21
|
+
"complexTypes": [
|
|
22
|
+
"TranscodeResult",
|
|
23
|
+
"TranscodeOptions"
|
|
24
|
+
],
|
|
25
|
+
"slug": "transcode"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "cancel",
|
|
29
|
+
"signature": "() => Promise<void>",
|
|
30
|
+
"parameters": [],
|
|
31
|
+
"returns": "Promise<void>",
|
|
32
|
+
"tags": [],
|
|
33
|
+
"docs": "Stop the running `transcode`, which then rejects with `CANCELLED` and leaves\nno file behind. Resolves straight away, and does nothing when none is\nrunning. On the web it rejects with the `UNIMPLEMENTED` code.",
|
|
34
|
+
"complexTypes": [],
|
|
35
|
+
"slug": "cancel"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "addListener",
|
|
39
|
+
"signature": "(eventName: 'progress', listenerFunc: (event: TranscodeProgress) => void) => Promise<PluginListenerHandle>",
|
|
40
|
+
"parameters": [
|
|
41
|
+
{
|
|
42
|
+
"name": "eventName",
|
|
43
|
+
"docs": "",
|
|
44
|
+
"type": "'progress'"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "listenerFunc",
|
|
48
|
+
"docs": "",
|
|
49
|
+
"type": "(event: TranscodeProgress) => void"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"returns": "Promise<PluginListenerHandle>",
|
|
53
|
+
"tags": [],
|
|
54
|
+
"docs": "Listen to the progress of the running `transcode`.",
|
|
55
|
+
"complexTypes": [
|
|
56
|
+
"PluginListenerHandle",
|
|
57
|
+
"TranscodeProgress"
|
|
58
|
+
],
|
|
59
|
+
"slug": "addlistenerprogress-"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "removeAllListeners",
|
|
63
|
+
"signature": "() => Promise<void>",
|
|
64
|
+
"parameters": [],
|
|
65
|
+
"returns": "Promise<void>",
|
|
66
|
+
"tags": [],
|
|
67
|
+
"docs": "Remove every listener of this plugin.",
|
|
68
|
+
"complexTypes": [],
|
|
69
|
+
"slug": "removealllisteners"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"properties": []
|
|
73
|
+
},
|
|
74
|
+
"interfaces": [
|
|
75
|
+
{
|
|
76
|
+
"name": "TranscodeResult",
|
|
77
|
+
"slug": "transcoderesult",
|
|
78
|
+
"docs": "",
|
|
79
|
+
"tags": [],
|
|
80
|
+
"methods": [],
|
|
81
|
+
"properties": [
|
|
82
|
+
{
|
|
83
|
+
"name": "path",
|
|
84
|
+
"tags": [],
|
|
85
|
+
"docs": "The absolute path of the MP4 that was written.",
|
|
86
|
+
"complexTypes": [],
|
|
87
|
+
"type": "string"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "uri",
|
|
91
|
+
"tags": [],
|
|
92
|
+
"docs": "The same file as a `file://` URL.",
|
|
93
|
+
"complexTypes": [],
|
|
94
|
+
"type": "string"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "width",
|
|
98
|
+
"tags": [],
|
|
99
|
+
"docs": "The width of the video as a player shows it, upright: the rotation the\noutput carries as metadata is already applied.",
|
|
100
|
+
"complexTypes": [],
|
|
101
|
+
"type": "number"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "height",
|
|
105
|
+
"tags": [],
|
|
106
|
+
"docs": "The height of the video as a player shows it, upright.",
|
|
107
|
+
"complexTypes": [],
|
|
108
|
+
"type": "number"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "durationMs",
|
|
112
|
+
"tags": [],
|
|
113
|
+
"docs": "The length of the output, in milliseconds.",
|
|
114
|
+
"complexTypes": [],
|
|
115
|
+
"type": "number"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "size",
|
|
119
|
+
"tags": [],
|
|
120
|
+
"docs": "The size of the output file, in bytes.",
|
|
121
|
+
"complexTypes": [],
|
|
122
|
+
"type": "number"
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "TranscodeOptions",
|
|
128
|
+
"slug": "transcodeoptions",
|
|
129
|
+
"docs": "",
|
|
130
|
+
"tags": [],
|
|
131
|
+
"methods": [],
|
|
132
|
+
"properties": [
|
|
133
|
+
{
|
|
134
|
+
"name": "source",
|
|
135
|
+
"tags": [],
|
|
136
|
+
"docs": "The video to transcode: an absolute path or a `file://` URL. On Android it\ncan also be a `content://` URI, such as the one a system picker returns; it\nis read in place, without a copy.",
|
|
137
|
+
"complexTypes": [],
|
|
138
|
+
"type": "string"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "destination",
|
|
142
|
+
"tags": [],
|
|
143
|
+
"docs": "Where to write the MP4: an absolute path or a `file://` URL. A file that is\nalready there is replaced, and missing directories are created.\n\nWhen it is left out, a new file is created in the app's cache directory\n(Android) or temporary directory (iOS). The app owns that file and deletes it\nwhen it is done with it.",
|
|
144
|
+
"complexTypes": [],
|
|
145
|
+
"type": "string | undefined"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"name": "maxSize",
|
|
149
|
+
"tags": [
|
|
150
|
+
{
|
|
151
|
+
"text": "1280",
|
|
152
|
+
"name": "default"
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"docs": "The longest side of the output, in pixels. A larger video is scaled down to\nfit, keeping its aspect ratio; a smaller one keeps its size. Both sides are\nrounded down to an even number, as H.264 4:2:0 needs. A whole number, at\nleast 16.",
|
|
156
|
+
"complexTypes": [],
|
|
157
|
+
"type": "number | undefined"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"name": "maxDurationMs",
|
|
161
|
+
"tags": [
|
|
162
|
+
{
|
|
163
|
+
"text": "30000",
|
|
164
|
+
"name": "default"
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"docs": "The longest the output may be, in milliseconds. A longer video is cut at\nthis point; a shorter one is kept whole. A whole number, at least 1.",
|
|
168
|
+
"complexTypes": [],
|
|
169
|
+
"type": "number | undefined"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"name": "videoBitrate",
|
|
173
|
+
"tags": [
|
|
174
|
+
{
|
|
175
|
+
"text": "1800000",
|
|
176
|
+
"name": "default"
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
"docs": "The average video bitrate the encoder is asked for, in bits per second.\nA whole number, at least 1.",
|
|
180
|
+
"complexTypes": [],
|
|
181
|
+
"type": "number | undefined"
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "PluginListenerHandle",
|
|
187
|
+
"slug": "pluginlistenerhandle",
|
|
188
|
+
"docs": "",
|
|
189
|
+
"tags": [],
|
|
190
|
+
"methods": [],
|
|
191
|
+
"properties": [
|
|
192
|
+
{
|
|
193
|
+
"name": "remove",
|
|
194
|
+
"tags": [],
|
|
195
|
+
"docs": "",
|
|
196
|
+
"complexTypes": [],
|
|
197
|
+
"type": "() => Promise<void>"
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"name": "TranscodeProgress",
|
|
203
|
+
"slug": "transcodeprogress",
|
|
204
|
+
"docs": "",
|
|
205
|
+
"tags": [],
|
|
206
|
+
"methods": [],
|
|
207
|
+
"properties": [
|
|
208
|
+
{
|
|
209
|
+
"name": "progress",
|
|
210
|
+
"tags": [],
|
|
211
|
+
"docs": "How far the running `transcode` is, from 0 to 1. It only grows, and the\nlast event before the promise resolves is 1.",
|
|
212
|
+
"complexTypes": [],
|
|
213
|
+
"type": "number"
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
"enums": [],
|
|
219
|
+
"typeAliases": [],
|
|
220
|
+
"pluginConfigs": []
|
|
221
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
export interface TranscodeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The video to transcode: an absolute path or a `file://` URL. On Android it
|
|
5
|
+
* can also be a `content://` URI, such as the one a system picker returns; it
|
|
6
|
+
* is read in place, without a copy.
|
|
7
|
+
*/
|
|
8
|
+
source: string;
|
|
9
|
+
/**
|
|
10
|
+
* Where to write the MP4: an absolute path or a `file://` URL. A file that is
|
|
11
|
+
* already there is replaced, and missing directories are created.
|
|
12
|
+
*
|
|
13
|
+
* When it is left out, a new file is created in the app's cache directory
|
|
14
|
+
* (Android) or temporary directory (iOS). The app owns that file and deletes it
|
|
15
|
+
* when it is done with it.
|
|
16
|
+
*/
|
|
17
|
+
destination?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The longest side of the output, in pixels. A larger video is scaled down to
|
|
20
|
+
* fit, keeping its aspect ratio; a smaller one keeps its size. Both sides are
|
|
21
|
+
* rounded down to an even number, as H.264 4:2:0 needs. A whole number, at
|
|
22
|
+
* least 16.
|
|
23
|
+
*
|
|
24
|
+
* @default 1280
|
|
25
|
+
*/
|
|
26
|
+
maxSize?: number;
|
|
27
|
+
/**
|
|
28
|
+
* The longest the output may be, in milliseconds. A longer video is cut at
|
|
29
|
+
* this point; a shorter one is kept whole. A whole number, at least 1.
|
|
30
|
+
*
|
|
31
|
+
* @default 30000
|
|
32
|
+
*/
|
|
33
|
+
maxDurationMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* The average video bitrate the encoder is asked for, in bits per second.
|
|
36
|
+
* A whole number, at least 1.
|
|
37
|
+
*
|
|
38
|
+
* @default 1800000
|
|
39
|
+
*/
|
|
40
|
+
videoBitrate?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface TranscodeResult {
|
|
43
|
+
/**
|
|
44
|
+
* The absolute path of the MP4 that was written.
|
|
45
|
+
*/
|
|
46
|
+
path: string;
|
|
47
|
+
/**
|
|
48
|
+
* The same file as a `file://` URL.
|
|
49
|
+
*/
|
|
50
|
+
uri: string;
|
|
51
|
+
/**
|
|
52
|
+
* The width of the video as a player shows it, upright: the rotation the
|
|
53
|
+
* output carries as metadata is already applied.
|
|
54
|
+
*/
|
|
55
|
+
width: number;
|
|
56
|
+
/**
|
|
57
|
+
* The height of the video as a player shows it, upright.
|
|
58
|
+
*/
|
|
59
|
+
height: number;
|
|
60
|
+
/**
|
|
61
|
+
* The length of the output, in milliseconds.
|
|
62
|
+
*/
|
|
63
|
+
durationMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* The size of the output file, in bytes.
|
|
66
|
+
*/
|
|
67
|
+
size: number;
|
|
68
|
+
}
|
|
69
|
+
export interface TranscodeProgress {
|
|
70
|
+
/**
|
|
71
|
+
* How far the running `transcode` is, from 0 to 1. It only grows, and the
|
|
72
|
+
* last event before the promise resolves is 1.
|
|
73
|
+
*/
|
|
74
|
+
progress: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Why a call failed, as the `code` of the rejected error.
|
|
78
|
+
*
|
|
79
|
+
* - `INVALID_ARGUMENT`: `source` is missing or not a path or URL this platform reads, `destination` is not a path or
|
|
80
|
+
* `file://` URL, or a number is out of range or not a whole number
|
|
81
|
+
* - `NOT_FOUND`: there is no file at `source`, or it cannot be opened
|
|
82
|
+
* - `NO_VIDEO`: `source` is not a video this device reads, or has no video track
|
|
83
|
+
* - `BUSY`: another `transcode` is still running; one runs at a time
|
|
84
|
+
* - `CANCELLED`: `cancel()` stopped it; nothing is left at the destination
|
|
85
|
+
* - `FAILED`: the decoder, the encoder or the writer failed; nothing is left at the destination
|
|
86
|
+
*/
|
|
87
|
+
export type TranscodeErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'NO_VIDEO' | 'BUSY' | 'CANCELLED' | 'FAILED';
|
|
88
|
+
export interface VideoTranscoderPlugin {
|
|
89
|
+
/**
|
|
90
|
+
* Transcode a video into an MP4 that web browsers play:
|
|
91
|
+
*
|
|
92
|
+
* - H.264 video (High profile where the encoder offers it), 8-bit 4:2:0, tagged
|
|
93
|
+
* BT.709; an HDR source (HLG or PQ) is tone-mapped to SDR
|
|
94
|
+
* - AAC audio: an AAC source track is copied as it is, anything else is
|
|
95
|
+
* encoded; a video without sound gets no audio track
|
|
96
|
+
* - the longer side at most `maxSize`, cut at `maxDurationMs`
|
|
97
|
+
* - the index (`moov`) before the media data, so playback can start before
|
|
98
|
+
* the whole file has downloaded
|
|
99
|
+
* - a portrait video keeps its rotation as track metadata, the way a phone
|
|
100
|
+
* records it, so players show it upright
|
|
101
|
+
*
|
|
102
|
+
* One runs at a time: a second call while one is running rejects with `BUSY`.
|
|
103
|
+
* On the web it rejects with the `UNIMPLEMENTED` code.
|
|
104
|
+
*/
|
|
105
|
+
transcode(options: TranscodeOptions): Promise<TranscodeResult>;
|
|
106
|
+
/**
|
|
107
|
+
* Stop the running `transcode`, which then rejects with `CANCELLED` and leaves
|
|
108
|
+
* no file behind. Resolves straight away, and does nothing when none is
|
|
109
|
+
* running. On the web it rejects with the `UNIMPLEMENTED` code.
|
|
110
|
+
*/
|
|
111
|
+
cancel(): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Listen to the progress of the running `transcode`.
|
|
114
|
+
*/
|
|
115
|
+
addListener(eventName: 'progress', listenerFunc: (event: TranscodeProgress) => void): Promise<PluginListenerHandle>;
|
|
116
|
+
/**
|
|
117
|
+
* Remove every listener of this plugin.
|
|
118
|
+
*/
|
|
119
|
+
removeAllListeners(): Promise<void>;
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\nexport interface TranscodeOptions {\n /**\n * The video to transcode: an absolute path or a `file://` URL. On Android it\n * can also be a `content://` URI, such as the one a system picker returns; it\n * is read in place, without a copy.\n */\n source: string;\n /**\n * Where to write the MP4: an absolute path or a `file://` URL. A file that is\n * already there is replaced, and missing directories are created.\n *\n * When it is left out, a new file is created in the app's cache directory\n * (Android) or temporary directory (iOS). The app owns that file and deletes it\n * when it is done with it.\n */\n destination?: string;\n /**\n * The longest side of the output, in pixels. A larger video is scaled down to\n * fit, keeping its aspect ratio; a smaller one keeps its size. Both sides are\n * rounded down to an even number, as H.264 4:2:0 needs. A whole number, at\n * least 16.\n *\n * @default 1280\n */\n maxSize?: number;\n /**\n * The longest the output may be, in milliseconds. A longer video is cut at\n * this point; a shorter one is kept whole. A whole number, at least 1.\n *\n * @default 30000\n */\n maxDurationMs?: number;\n /**\n * The average video bitrate the encoder is asked for, in bits per second.\n * A whole number, at least 1.\n *\n * @default 1800000\n */\n videoBitrate?: number;\n}\n\nexport interface TranscodeResult {\n /**\n * The absolute path of the MP4 that was written.\n */\n path: string;\n /**\n * The same file as a `file://` URL.\n */\n uri: string;\n /**\n * The width of the video as a player shows it, upright: the rotation the\n * output carries as metadata is already applied.\n */\n width: number;\n /**\n * The height of the video as a player shows it, upright.\n */\n height: number;\n /**\n * The length of the output, in milliseconds.\n */\n durationMs: number;\n /**\n * The size of the output file, in bytes.\n */\n size: number;\n}\n\nexport interface TranscodeProgress {\n /**\n * How far the running `transcode` is, from 0 to 1. It only grows, and the\n * last event before the promise resolves is 1.\n */\n progress: number;\n}\n\n/**\n * Why a call failed, as the `code` of the rejected error.\n *\n * - `INVALID_ARGUMENT`: `source` is missing or not a path or URL this platform reads, `destination` is not a path or\n * `file://` URL, or a number is out of range or not a whole number\n * - `NOT_FOUND`: there is no file at `source`, or it cannot be opened\n * - `NO_VIDEO`: `source` is not a video this device reads, or has no video track\n * - `BUSY`: another `transcode` is still running; one runs at a time\n * - `CANCELLED`: `cancel()` stopped it; nothing is left at the destination\n * - `FAILED`: the decoder, the encoder or the writer failed; nothing is left at the destination\n */\nexport type TranscodeErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'NO_VIDEO' | 'BUSY' | 'CANCELLED' | 'FAILED';\n\nexport interface VideoTranscoderPlugin {\n /**\n * Transcode a video into an MP4 that web browsers play:\n *\n * - H.264 video (High profile where the encoder offers it), 8-bit 4:2:0, tagged\n * BT.709; an HDR source (HLG or PQ) is tone-mapped to SDR\n * - AAC audio: an AAC source track is copied as it is, anything else is\n * encoded; a video without sound gets no audio track\n * - the longer side at most `maxSize`, cut at `maxDurationMs`\n * - the index (`moov`) before the media data, so playback can start before\n * the whole file has downloaded\n * - a portrait video keeps its rotation as track metadata, the way a phone\n * records it, so players show it upright\n *\n * One runs at a time: a second call while one is running rejects with `BUSY`.\n * On the web it rejects with the `UNIMPLEMENTED` code.\n */\n transcode(options: TranscodeOptions): Promise<TranscodeResult>;\n\n /**\n * Stop the running `transcode`, which then rejects with `CANCELLED` and leaves\n * no file behind. Resolves straight away, and does nothing when none is\n * running. On the web it rejects with the `UNIMPLEMENTED` code.\n */\n cancel(): Promise<void>;\n\n /**\n * Listen to the progress of the running `transcode`.\n */\n addListener(eventName: 'progress', listenerFunc: (event: TranscodeProgress) => void): Promise<PluginListenerHandle>;\n\n /**\n * Remove every listener of this plugin.\n */\n removeAllListeners(): Promise<void>;\n}\n"]}
|