Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ an [upload URL created by a backend server](https://docs.mux.com/guides/video/up

Android API 23+, Kotlin 2.1+, AGP 8.10+, and `compileSdk` 36+.

On Android 6.0 (API 23), the SDK uploads original files without standardization. On-device conversion requires Android 7.0 (API 24)+ and a supported input and device.

## Usage

To use this SDK, you must first add it as a dependency. The Mux Android Upload SDK is
Expand Down Expand Up @@ -99,6 +101,8 @@ Use `Preset2560x1440` for 1440p. Configure the matching `new_asset_settings.max_

To request HDR-to-SDR tone mapping, use `HdrHandling.ToneMapToSDR`. Support depends on the input and device; HDR preservation does not guarantee HDR playback. Unsupported or failed preparation uploads the original unless canceled. Files with multiple audio tracks also upload unchanged.

To see why a file was converted or uploaded unchanged, enable SDK logging with `MuxUploadSdk.useLogger(MuxUploadSdk.logcatLogger())`. Decisions are logged under the `MuxUploadStandardization` tag, for example `standard_input event=plan_selected plan=convert ...`.

#### Try it out

An example use case can be found in our [Example App](app/).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@ import java.io.File
suspend fun extractThumbnail(videoFile: File): Bitmap? {
val thumbnailBitmap = withContext(Dispatchers.IO) {
try {
MediaMetadataRetriever().use {
it.setDataSource(videoFile.absolutePath)
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(videoFile.absolutePath)
val frameTime =
it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLong()
?.div(2)
?: 0
it.getFrameAtTime(frameTime * 1000)
retriever.getFrameAtTime(frameTime * 1000)
} finally {
retriever.release()
}
} catch (e: Exception) {
Log.d("CreateUploadViewModel", "Error getting thumb bitmap")
null
}
} // val thumbnailBitmap = ...
return thumbnailBitmap
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,29 @@ private fun BodyContent(
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = modifier
// Keep the Upload button above the system navigation bar (edge-to-edge on Android 15+)
.navigationBarsPadding()
.padding(top = 64.dp, bottom = 12.dp)
.fillMaxSize()
) {
if (state.thumbnail != null) {
ChosenThumbnail(
thumbnail = state.thumbnail,
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
)
// A video can be uploaded even when the device can't render a preview frame for it
if (state.prepareState == CreateUploadViewModel.PrepareState.READY) {
if (state.thumbnail != null) {
ChosenThumbnail(
thumbnail = state.thumbnail,
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
)
} else {
Box(
modifier = Modifier
.padding(start = 20.dp, end = 20.dp)
.fillMaxWidth()
.height(THUMBNAIL_SIZE),
contentAlignment = Alignment.Center
) {
Text(text = "Preview unavailable")
}
}
DefaultButton(
onClick = startUpload,
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.FloatingActionButton
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.Icon
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
Expand Down Expand Up @@ -108,6 +109,8 @@ private fun ScreenContent(
private fun CreateUploadFab(uploadClick: () -> Unit) {
FloatingActionButton(
onClick = { uploadClick() },
// Keep the button above the system navigation bar (edge-to-edge on Android 15+)
modifier = Modifier.navigationBarsPadding(),
backgroundColor = MaterialTheme.colors.primary,
) {
Icon(painterResource(id = R.drawable.ic_add), contentDescription = "Start new upload")
Expand Down
1 change: 0 additions & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ dependencies {

implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "io.github.crow-misia.libyuv:libyuv-android:0.43.2"

implementation 'androidx.core:core-ktx:1.13.1'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package com.mux.video.upload.api
/**
* Requested HDR behavior for input standardization.
*
* Currently retained as configuration for the new Standard Input pipeline. The legacy
* transcoder does not yet apply this option.
* HDR inputs that already meet Mux's standard input requirements upload unchanged with
* [Preserve]. [ToneMapToSDR] converts supported HLG/PQ inputs to SDR on the device. When the
* device or input cannot support the requested conversion, the original file is uploaded.
*/
enum class HdrHandling {
/** Request preservation of eligible HDR. This is the default; HDR playback is not guaranteed. */
Expand Down
15 changes: 9 additions & 6 deletions library/src/main/java/com/mux/video/upload/api/MuxUpload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +492,13 @@ class MuxUpload private constructor(
* The Upload SDK will upload your file in smaller chunks, which can be more reliable in adverse
* network conditions.
*
* @param sizeBytes The chunk size in bytes. Mux's default is 8M
* @param sizeBytes The chunk size in bytes. Mux's default is 8M. The size is rounded down
* to a multiple of 256 KiB (minimum 256 KiB).
*/
@Suppress("unused")
fun chunkSize(sizeBytes: Int): Builder {
uploadInfo.update(chunkSize = sizeBytes)
val unit = 256 * 1024
uploadInfo = uploadInfo.update(chunkSize = maxOf(unit, sizeBytes / unit * unit))
return this
}

Expand All @@ -509,26 +511,27 @@ class MuxUpload private constructor(
*/
@Suppress("unused")
fun optOutOfEventTracking(optOut: Boolean): Builder {
uploadInfo.update(optOut = optOut)
uploadInfo = uploadInfo.update(optOut = optOut)
return this
}

/**
* The Upload SDK will upload your file in smaller chunks, which can be more reliable in adverse
* network conditions. Each chunk can be retried individually, up to the given number of times
*
* @param retries The number of retries per chunk. Mux's default is 3
* @param retries The number of retries per chunk. Mux's default is 3; negative values use 0.
*/
@Suppress("unused")
fun retriesPerChunk(retries: Int): Builder {
uploadInfo.update(retriesPerChunk = retries)
uploadInfo = uploadInfo.update(retriesPerChunk = retries.coerceAtLeast(0))
return this
}

/**
* Creates a new [MuxUpload] with the given configuration.
*/
fun build() = MuxUpload(uploadInfo, manageTask)
// Each built upload needs its own session, including when the Builder is reused.
fun build() = MuxUpload(uploadInfo.copy(), manageTask)
}

internal companion object {
Expand Down
176 changes: 0 additions & 176 deletions library/src/main/java/com/mux/video/upload/internal/Resample.kt

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.mux.video.upload.api.MuxUpload
import com.mux.video.upload.api.MuxUploadManager
import com.mux.video.upload.api.UploadStatus
import com.mux.video.upload.internal.standardization.SdrStandardizedFile
import com.mux.video.upload.internal.standardization.StandardInputDiagnostics
import com.mux.video.upload.internal.standardization.PreparedUpload
import com.mux.video.upload.internal.standardization.UploadPreparation
import kotlinx.coroutines.*
Expand Down Expand Up @@ -131,9 +132,10 @@ internal class UploadJobFactory internal constructor(
state.standardizedState = record
withContext(Dispatchers.IO) { UploadPersistence.writeStandardized(upload, record) }
attempt.selectPayload(output.file.length(), 0)
StandardInputDiagnostics.payloadSelected(standardized = true, bytes = output.file.length())
return output.file
}
is PreparedUpload.Original -> MuxUploadSdk.logger.d("MuxUploadPreparation", result.diagnostic.toString())
is PreparedUpload.Original -> StandardInputDiagnostics.preparationOriginal(result.diagnostic)
}
}
return selectOriginal(upload)
Expand All @@ -142,6 +144,7 @@ internal class UploadJobFactory internal constructor(
private suspend fun selectOriginal(upload: UploadInfo): File = withContext(Dispatchers.IO) {
UploadPersistence.selectOriginal(upload)
checkNotNull(upload.attempt).preparation.apply { originalSelected = true; standardizedState = null }
if (upload.isStandardizationRequested()) StandardInputDiagnostics.payloadSelected(standardized = false, bytes = upload.inputFile.length())
upload.inputFile
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ internal class UploadMetrics private constructor() {
if (inputFileDurationMs <= 0) {
videoDuration = withContext(Dispatchers.IO) {
try {
MediaMetadataRetriever().use { retriever ->
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(uploadInfo.inputFile.absolutePath)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toInt()
} finally {
retriever.release()
}
} catch (e: Exception) {
MuxUploadSdk.logger.e("UploadMetrics", "Failed to get video duration", e)
Expand Down
Loading