Repository navigation
NAT-547: Add cancellable Media3 SDR conversion adapter #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
454cc33
b9f3e3e
b710a35
69327dc
d5d1bb8
184834f
fcae48c
707f47c
d39b5ed
fff4029
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()) | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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() | ||
| } | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.