Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package com.mux.video.upload.internal.standardization

import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.metrics.LogSessionId
import android.net.Uri
import android.os.Build
import android.os.Looper
import android.util.Pair
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.Clock
import androidx.media3.common.util.MediaFormatUtil
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Presentation
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.transformer.AudioEncoderSettings
import androidx.media3.transformer.Codec
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultCodec
import androidx.media3.transformer.DefaultDecoderFactory
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.EditedMediaItemSequence
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExoPlayerAssetLoader
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.TransformationRequest
import androidx.media3.transformer.Transformer
import java.io.File

@UnstableApi
internal class Media3SdrExportEngine(
private val context: Context, private val looper: Looper,
private val conversion: StandardInputConversion, private val targets: SdrEncodingTargets,
private val capability: SdrEncoderCapability,
) : SdrExportEngine {
private var transformer: Transformer? = null

override fun start(input: File, output: File, completed: (String?, String?) -> Unit,
failed: (SdrConversionFailure) -> Unit) {
check(Looper.myLooper() == looper)
val decoder = DefaultDecoderFactory.Builder(context).setEnableDecoderFallback(false).build()
val assetLoader = ExoPlayerAssetLoader.Factory(context, decoder, Clock.DEFAULT, null,
{ ctx -> FirstAudioTrackSelector(ctx, targets.firstAudioTrackId) }, null, null)
val item = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(input)))
.setRemoveAudio(targets.audioChannels == null)
.setEffects(Effects(emptyList(), listOf(Presentation.createForWidthAndHeight(
conversion.outputDimensions.width, conversion.outputDimensions.height, Presentation.LAYOUT_SCALE_TO_FIT))))
if (conversion.outputCadence == OutputCadence.ConstantFrameRate) item.setFrameRate(conversion.outputFrameRate.toInt())
Comment thread
eugene-mux marked this conversation as resolved.
Resolved
val items = listOf(item.build())
val sequence = if (targets.audioChannels == null) EditedMediaItemSequence.withVideoFrom(items)
else EditedMediaItemSequence.withAudioAndVideoFrom(items)
val composition = Composition.Builder(sequence)
.setHdrMode(Composition.HDR_MODE_KEEP_HDR).build()
val exporter = Transformer.Builder(context)
.setLooper(looper)
.setPortraitEncodingEnabled(true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason why you chose to enable this? Landscape with a rotation matrix might be better supported by on-device encoders and I don't think standard input is opinionated here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks. I’ll address this in a later PR in the stack.

.setVideoMimeType(targets.videoMime)
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.setAssetLoaderFactory(assetLoader)
.setEncoderFactory(StrictSdrEncoderFactory(context, conversion, targets, capability))
// Retain Media3's default no-output-sample watchdog (10s; 25s on emulators).
.addListener(SdrExportListener(targets, completed, failed)).build()
transformer = exporter
exporter.start(composition, output.absolutePath)
}

override fun cancel() {
check(Looper.myLooper() == looper)
transformer?.cancel()
transformer = null
}
}

@UnstableApi
internal class SdrExportListener(
private val targets: SdrEncodingTargets, private val completed: (String?, String?) -> Unit,
private val failed: (SdrConversionFailure) -> Unit,
) : Transformer.Listener {
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
if (targets.audioChannels != null && (exportResult.channelCount != targets.audioChannels ||
exportResult.sampleRate != targets.audioSampleRate)) failed(SdrConversionFailure.OutputInvalid)
else completed(exportResult.videoMimeType, exportResult.audioMimeType)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
failed(SdrConversionFailure.Export)
}
override fun onFallbackApplied(composition: Composition, originalTransformationRequest: TransformationRequest,
fallbackTransformationRequest: TransformationRequest) {
failed(SdrConversionFailure.ForbiddenFallback)
}
}

/** MP4 Format.id is the container track ID. Never choose a preferred/default secondary track. */
@UnstableApi
internal class FirstAudioTrackSelector(context: Context, private val trackId: String?) : DefaultTrackSelector(context) {
init {
setParameters(buildUponParameters().setForceHighestSupportedBitrate(true)
.setExceedAudioConstraintsIfNecessary(true).setExceedRendererCapabilitiesIfNecessary(true))
}
override fun selectAudioTrack(mappedTrackInfo: MappedTrackInfo, rendererFormatSupports: Array<Array<IntArray>>,
rendererMixedMimeTypeAdaptationSupports: IntArray, params: Parameters): Pair<ExoTrackSelection.Definition, Int>? {
if (trackId == null) return null
for (renderer in 0 until mappedTrackInfo.rendererCount) {
if (mappedTrackInfo.getRendererType(renderer) != C.TRACK_TYPE_AUDIO) continue
val groups = mappedTrackInfo.getTrackGroups(renderer)
for (groupIndex in 0 until groups.length) {
val group = groups[groupIndex]
for (track in 0 until group.length) {
if (group.getFormat(track).id == trackId) return Pair(ExoTrackSelection.Definition(group, track), renderer)
}
}
}
error("First audio track identity unavailable")
}
}

/**
* Media3's default video factory overrides H.264 profile requests. Supply its DefaultCodec with
* exact platform settings instead; Transformer still owns the entire decode/effect/encode pipeline.
* No alternative encoder, size, profile, MIME, or HDR mode is attempted on configuration failure.
*/
@UnstableApi
internal class StrictSdrEncoderFactory(
private val context: Context, private val conversion: StandardInputConversion,
private val targets: SdrEncodingTargets, private val capability: SdrEncoderCapability,
) : Codec.EncoderFactory {
private val audioDelegate = DefaultEncoderFactory.Builder(context)
.setEnableFallback(false).setEnableFormatFallback(false)
.setRequestedAudioEncoderSettings(AudioEncoderSettings.Builder().setBitrate(SdrEncodingTargets.AAC_BITRATE)
.setProfile(SdrEncodingTargets.AAC_PROFILE).build()).build()

override fun videoNeedsEncoding() = true
override fun audioNeedsEncoding() = targets.audioChannels != null && !targets.copyAac

override fun createForAudioEncoding(format: Format, logSessionId: LogSessionId?): Codec {
check(format.sampleMimeType == MimeTypes.AUDIO_AAC && format.channelCount == targets.audioChannels &&
format.sampleRate == targets.audioSampleRate)
return audioDelegate.createForAudioEncoding(format, logSessionId)
}

override fun createForVideoEncoding(format: Format, logSessionId: LogSessionId?): Codec {
val exact = requestedVideoFormat(format)
val media = SdrVideoEncoderConfiguration.create(exact, targets, capability.level)
return DefaultCodec(context, exact, media, capability.name, false, null)
}

private fun requestedVideoFormat(format: Format): Format {
check(format.sampleMimeType == targets.videoMime && format.width == conversion.outputDimensions.width &&
format.height == conversion.outputDimensions.height && format.rotationDegrees == 0 &&
format.colorInfo?.colorTransfer == C.COLOR_TRANSFER_SDR)
return format.buildUpon().setFrameRate(conversion.outputFrameRate.toFloat()).setAverageBitrate(targets.bitrate).build()
}
}

/** Shared preflight/export keys; capability metadata remains only an advertised-format guard. */
@UnstableApi
internal object SdrVideoEncoderConfiguration {
fun create(format: Format, targets: SdrEncodingTargets, level: Int,
sdk: Int = Build.VERSION.SDK_INT, device: String = Build.DEVICE,
soc: String = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL else ""): MediaFormat {
// Media3 forces a 30-fps hint on this device. Other rates have unproven GOP/rate
// control here; preflight rejects them instead of changing the planned settings.
check(sdk >= 30 || device != "joyeuse" || format.frameRate == 30f)
val media = MediaFormatUtil.createMediaFormatFromFormat(format)
media.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
media.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
media.setInteger(MediaFormat.KEY_PROFILE, targets.platformProfile)
media.setInteger(MediaFormat.KEY_LEVEL, level)
media.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, targets.keyframeIntervalSeconds)
if (sdk >= 29) media.setInteger(MediaFormat.KEY_MAX_B_FRAMES, 0)
// Media3 1.11.1's empirical encoder performance settings, retaining its exceptions.
if (sdk >= 25) {
media.setInteger(MediaFormat.KEY_PRIORITY, 1)
val overflow = sdk in 31..34 && soc in setOf("SM8550", "SM7450", "SM6450", "SC9863A", "T612", "T606", "T603")
media.setInteger(MediaFormat.KEY_OPERATING_RATE, if (sdk == 26) 30 else if (overflow) 1000 else Int.MAX_VALUE)
}
return media
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.mux.video.upload.internal.standardization

import android.content.Context
import android.os.Looper
import androidx.media3.common.MimeTypes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume

internal sealed interface SdrConversionResult {
/** Caller owns retention until upload/resume reaches its terminal point. */
data class Completed(val output: SdrGeneratedFile, val facts: MediaFacts) : SdrConversionResult
data class Failed(val reason: SdrConversionFailure) : SdrConversionResult
data object Cancelled : SdrConversionResult
}

/** The only deletion authority returned to orchestration; never accepts an arbitrary path. */
internal class SdrGeneratedFile private constructor(val file: File) {
fun delete(): Boolean = !file.exists() || file.delete()

companion object {
fun allocate(cacheDir: File, requiredBytes: Long): SdrGeneratedFile? {
val area = File(cacheDir.canonicalFile, "mux-upload/standard-input")
if ((!area.isDirectory && !area.mkdirs()) || area.canonicalFile != area.absoluteFile || area.usableSpace < requiredBytes) return null
return SdrGeneratedFile(File.createTempFile("sdr-", ".mp4", area))
}
}
}

/** Internal boundary for Media3 export and cancellation. */
internal interface SdrExportEngine {
fun start(input: File, output: File, completed: (String?, String?) -> Unit,
failed: (SdrConversionFailure) -> Unit)
fun cancel()
}

/** Each attempt owns a coroutine job, one Transformer, and one explicitly tracked output file. */
internal class SdrConversionAdapter(
context: Context,
private val preflight: (StandardInputConversion, MediaMetadataInspection, SdrEncodingTargets, () -> Boolean) -> SdrEncoderCapability? =
SdrCapabilityPreflight()::videoEncoder,
private val validate: (File, MediaFacts, StandardInputTimelineFacts, StandardInputConversion, () -> Boolean) -> StandardInputOutputValidation =
StandardInputOutputValidator()::validateGeneratedOutput,
private val createEngine: (Context, Looper, StandardInputConversion, SdrEncodingTargets, SdrEncoderCapability) -> SdrExportEngine =
::Media3SdrExportEngine,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val appContext = context.applicationContext

fun start(input: File, metadata: MediaMetadataInspection, source: MediaSampleInspection,
conversion: StandardInputConversion, onResult: (SdrConversionResult) -> Unit): Attempt =
Attempt(input, metadata, source, conversion, onResult).also { it.begin() }

inner class Attempt internal constructor(
private val input: File, private val metadata: MediaMetadataInspection,
private val source: MediaSampleInspection, private val conversion: StandardInputConversion,
private val onResult: (SdrConversionResult) -> Unit,
) {
private val job = Job()
private val scope = CoroutineScope(job + Dispatchers.Main)
private val terminalLock = Any()
private var terminal = false
private var engine: SdrExportEngine? = null
private var output: SdrGeneratedFile? = null
/** Exact deletion authority retained if the OS refuses cleanup, including cancellation. */
@Volatile var pendingCleanup: SdrGeneratedFile? = null
private set
private val sourceLength = input.length()
private val sourceModified = input.lastModified()

fun cancel() {
synchronized(terminalLock) {
if (terminal) return
job.cancel()
}
}

@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
internal fun begin() {
// Enter cleanup even when cancellation wins before the initial Main dispatch.
scope.launch(start = CoroutineStart.ATOMIC) {
val result = try {
ensureActive()
convert()
} catch (_: CancellationException) { SdrConversionResult.Cancelled }
catch (_: Exception) { SdrConversionResult.Failed(SdrConversionFailure.Export) }
catch (_: LinkageError) { SdrConversionResult.Failed(SdrConversionFailure.Export) }
withContext(NonCancellable) { finish(result) }
}
}

private suspend fun convert(): SdrConversionResult {
val targets = withContext(ioDispatcher) { SdrEncodingTargets.from(conversion, metadata, source) }
?: return SdrConversionResult.Failed(SdrConversionFailure.UnsupportedPlan)
val capability = withContext(ioDispatcher) { preflight(conversion, metadata, targets) { !job.isActive } }
?: return SdrConversionResult.Failed(SdrConversionFailure.CapabilityUnavailable)
val preparationFailure = withContext(ioDispatcher) {
if (!sourceUnchanged()) return@withContext SdrConversionFailure.SourceChanged
val required = source.timeline.durationSeconds.valueOrNull?.let(targets::estimatedOutputBytes)
?: return@withContext SdrConversionFailure.InsufficientSourceEvidence
// Record ownership before returning across a cancellable dispatcher boundary.
output = SdrGeneratedFile.allocate(appContext.cacheDir, required)
if (output == null) SdrConversionFailure.DiskSpace else null
}
if (preparationFailure != null) return SdrConversionResult.Failed(preparationFailure)
val owned = checkNotNull(output)
val exporter = createEngine(appContext, Looper.getMainLooper(), conversion, targets, capability)
engine = exporter
val exportFailure = suspendCancellableCoroutine<SdrConversionFailure?> { continuation ->
val delivered = AtomicBoolean(false)
continuation.invokeOnCancellation { delivered.set(true) }
fun complete(reason: SdrConversionFailure?) {
if (delivered.compareAndSet(false, true)) continuation.resume(reason)
}
exporter.start(input, owned.file, { videoMime, audioMime ->
complete(if (videoMime != targets.videoMime ||
audioMime != if (targets.audioChannels != null) MimeTypes.AUDIO_AAC else null)
SdrConversionFailure.CodecChanged else null)
}, ::complete)
}
if (exportFailure != null) return SdrConversionResult.Failed(exportFailure)
return withContext(ioDispatcher) {
if (!sourceUnchanged()) return@withContext SdrConversionResult.Failed(SdrConversionFailure.SourceChanged)
when (val validation = validate(owned.file, source.facts, source.timeline, conversion) { !job.isActive }) {
is StandardInputOutputValidation.Accepted -> {
if (!sourceUnchanged()) return@withContext SdrConversionResult.Failed(SdrConversionFailure.SourceChanged)
// The shared validator checks policy, pixels, cadence, and effective timeline.
// Add this adapter's concrete encoder-profile and non-AAC channel contract.
val audio = validation.facts.audioTracks.valueOrNull?.singleOrNull()?.format?.valueOrNull as? AudioFormat.Aac
val expectedLayout = aacChannelLayout(targets.audioChannels).valueOrNull
if (validation.facts.videoProfile != MediaFact.Known(targets.profile) ||
(targets.audioChannels != null && audio?.layout != expectedLayout))
SdrConversionResult.Failed(SdrConversionFailure.OutputInvalid)
else SdrConversionResult.Completed(owned, validation.facts)
}
is StandardInputOutputValidation.Rejected -> SdrConversionResult.Failed(SdrConversionFailure.OutputInvalid)
StandardInputOutputValidation.Cancelled -> SdrConversionResult.Cancelled
}
}
}

private fun sourceUnchanged() = input.isFile && input.canRead() && sourceLength > 0 &&
input.length() == sourceLength && input.lastModified() == sourceModified
private suspend fun finish(proposed: SdrConversionResult) {
var result = synchronized(terminalLock) {
if (terminal) return
terminal = true
if (job.isCancelled) SdrConversionResult.Cancelled else proposed
}
try {
runCatching { engine?.cancel() }
if (result !is SdrConversionResult.Completed && withContext(ioDispatcher) { output?.delete() } == false) {
pendingCleanup = output
// Keep ownership at its exact path if the OS refuses deletion; never sweep a directory.
if (result !is SdrConversionResult.Cancelled) result = SdrConversionResult.Failed(SdrConversionFailure.FileOwnership)
}
runCatching { onResult(result) }
} finally {
engine = null
scope.cancel()
}
}
}
}
Loading