Skip to content

Commit

Permalink
Initial implementation of video compression for Android (including ca…
Browse files Browse the repository at this point in the history
…ncellation option)
  • Loading branch information
mikedawson committed Apr 2, 2024
1 parent 0e56703 commit 28bf6fe
Show file tree
Hide file tree
Showing 28 changed files with 441 additions and 147 deletions.
Expand Up @@ -70,9 +70,12 @@ import com.ustadmobile.core.domain.cachestoragepath.GetStoragePathForUrlUseCaseC
import com.ustadmobile.core.domain.clipboard.SetClipboardStringUseCase
import com.ustadmobile.core.domain.clipboard.SetClipboardStringUseCaseAndroid
import com.ustadmobile.core.domain.compress.image.CompressImageUseCaseAndroid
import com.ustadmobile.core.domain.compress.video.CompressVideoUseCaseAndroid
import com.ustadmobile.core.domain.contententry.delete.DeleteContentEntryParentChildJoinUseCase
import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryGetMetaDataFromUriUseCase
import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryGetMetaDataFromUriUseCaseCommonJvm
import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCase
import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCaseAndroid
import com.ustadmobile.core.domain.contententry.importcontent.CreateRetentionLocksForManifestUseCase
import com.ustadmobile.core.domain.contententry.importcontent.CreateRetentionLocksForManifestUseCaseCommonJvm
import com.ustadmobile.core.domain.contententry.importcontent.EnqueueContentEntryImportUseCase
Expand Down Expand Up @@ -422,6 +425,7 @@ class UstadApp : Application(), DIAware, ImageLoaderFactory{
json = instance(),
getStoragePathForUrlUseCase = getStoragePathForUrlUseCase,
mimeTypeHelper = mimeTypeHelper,
compressUseCase = instance<CompressVideoUseCaseAndroid>(),
)
)

Expand All @@ -442,6 +446,13 @@ class UstadApp : Application(), DIAware, ImageLoaderFactory{
)
}

bind<CompressVideoUseCaseAndroid>() with singleton {
CompressVideoUseCaseAndroid(
appContext = applicationContext,
uriHelper = instance(),
)
}

bind<XmlPullParserFactory>(tag = DiTag.XPP_FACTORY_NSAWARE) with singleton {
XmlPullParserFactory.newInstance().also {
it.isNamespaceAware = true
Expand Down Expand Up @@ -579,6 +590,13 @@ class UstadApp : Application(), DIAware, ImageLoaderFactory{
)
}

bind<CancelImportContentEntryUseCase>() with scoped(EndpointScope.Default).provider {
CancelImportContentEntryUseCaseAndroid(
appContext = applicationContext,
endpoint = context,
)
}

bind<ImportContentEntryUseCase>() with scoped(EndpointScope.Default).provider {
ImportContentEntryUseCase(
db = instance(tag = DoorTag.TAG_DB),
Expand Down
Expand Up @@ -12,15 +12,13 @@ import com.ustadmobile.core.viewmodel.contententry.detailoverviewtab.ContentEntr
import com.ustadmobile.hooks.useUstadViewModel
import com.ustadmobile.lib.db.composites.ContentEntryAndDetail
import com.ustadmobile.lib.db.entities.*
import com.ustadmobile.mui.common.justifyContent
import com.ustadmobile.mui.common.md
import com.ustadmobile.mui.common.xs
import com.ustadmobile.mui.components.UstadQuickActionButton
import com.ustadmobile.mui.components.UstadRawHtml
import web.cssom.*
import mui.material.*
import mui.material.Badge
import mui.material.List
import mui.material.styles.TypographyVariant
import mui.system.Container
import mui.system.responsive
Expand Down Expand Up @@ -73,7 +71,6 @@ external interface ContentEntryDetailOverviewScreenProps : Props {

var onClickTranslation: (ContentEntryRelatedEntryJoinWithLanguage) -> Unit

var onClickContentJobItem: () -> Unit
}

val ContentEntryDetailOverviewComponent2 = FC<ContentEntryDetailOverviewScreenProps> { props ->
Expand Down Expand Up @@ -106,9 +103,9 @@ val ContentEntryDetailOverviewComponent2 = FC<ContentEntryDetailOverviewScreenPr
}
}

ContentJobList{
uiState = props.uiState
}
// ContentJobList{
// uiState = props.uiState
// }

if (props.uiState.locallyAvailable) {
LocallyAvailableRow()
Expand Down Expand Up @@ -299,41 +296,6 @@ private val ContentDetailRightColumn = FC <ContentEntryDetailOverviewScreenProps
}
}

private val ContentJobList = FC <ContentEntryDetailOverviewScreenProps> { props ->

List{
props.uiState.activeContentJobItems.forEach {
onClick = { props.onClickContentJobItem }
ListItemText{
sx {
padding = 10.px
}

Stack {
Stack {
direction = responsive(StackDirection.row)
justifyContent = JustifyContent.spaceBetween


Typography {
+ (it.progressTitle ?: "")
}

Typography {
+ (it.progress.toString()+" %")
}
}

LinearProgress {
value = it.progress / it.total
variant = LinearProgressVariant.determinate
}
}
}
}
}
}

private val LocallyAvailableRow = FC <ContentEntryDetailOverviewScreenProps> {

val strings: StringProvider = useStringProvider()
Expand Down Expand Up @@ -500,7 +462,7 @@ val ContentEntryDetailOverviewScreenPreview = FC<Props> {
}
}
),
activeContentJobItems = listOf(
activeImportJobs = listOf(
ContentJobItemProgress().apply {
progressTitle = "First"
progress = 30
Expand Down
2 changes: 2 additions & 0 deletions core/build.gradle
Expand Up @@ -263,6 +263,8 @@ kotlin {
implementation libs.compressor
implementation libs.coil
implementation libs.androidx.browser

implementation libs.transcoder
}
}

Expand Down
@@ -0,0 +1,101 @@
package com.ustadmobile.core.domain.compress.video

import android.content.Context
import android.net.Uri
import androidx.core.net.toFile
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.ustadmobile.core.domain.compress.CompressParams
import com.ustadmobile.core.domain.compress.CompressProgressUpdate
import com.ustadmobile.core.domain.compress.CompressResult
import com.ustadmobile.core.domain.compress.CompressUseCase
import com.ustadmobile.core.ext.requireExtension
import com.ustadmobile.core.uri.UriHelper
import com.ustadmobile.door.DoorUri
import com.ustadmobile.door.ext.toDoorUri
import io.github.aakira.napier.Napier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import java.io.File
import java.util.UUID

class CompressVideoUseCaseAndroid(
private val appContext: Context,
private val uriHelper: UriHelper,
): CompressUseCase {

override suspend fun invoke(
fromUri: String,
toUri: String?,
params: CompressParams,
onProgress: CompressUseCase.OnCompressProgress?
): CompressResult {
//As per https://developer.android.com/media/platform/supported-formats
val vidStrategy = DefaultVideoStrategy
.atMost(720, 1280)
.bitRate(500 * 1000)
.frameRate(30)
.mimeType("video/avc")
.build()

val destFile = if(toUri != null) {
Uri.parse(toUri).toFile().requireExtension("mp4")
}else {
File(appContext.cacheDir, UUID.randomUUID().toString() + ".mp4")
}

val sizeIn = uriHelper.getSize(DoorUri.parse(fromUri))
Napier.v { "CompressVideoUseCase: video size in: $sizeIn " }

val completable = CompletableDeferred<Int>()
val transcoder = Transcoder.into(destFile.absolutePath)
.addDataSource(appContext, Uri.parse(fromUri))
.setVideoTrackStrategy(vidStrategy)
.setListener(object: TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
Napier.v { "CompressVideoUseCase: progress: $progress completed=(${(progress * sizeIn).toLong()}" }
onProgress?.invoke(
CompressProgressUpdate(
fromUri = fromUri,
completed = (progress * sizeIn).toLong(),
total = sizeIn
)
)
}

override fun onTranscodeCompleted(p0: Int) {
Napier.v { "CompressVideoUseCase: completed: $p0" }
completable.complete(p0)
}

override fun onTranscodeCanceled() {

}

override fun onTranscodeFailed(p0: Throwable) {
Napier.e(throwable = p0) { "CompressVideoCase: failed"}
completable.completeExceptionally(p0)

}
})

try {
val future = transcoder.transcode()
try {
completable.await()
}catch(e: CancellationException) {
future.cancel(true)
throw e
}
}catch(e2: Throwable) {
Napier.e("CompressVideoUseCase: Exception", e2)
}


return CompressResult(
uri = destFile.toDoorUri().toString(),
mimeType = "video/avc"
)
}
}
@@ -0,0 +1,17 @@
package com.ustadmobile.core.domain.contententry.importcontent

import android.content.Context
import androidx.work.WorkManager
import com.ustadmobile.core.account.Endpoint

class CancelImportContentEntryUseCaseAndroid(
private val appContext: Context,
private val endpoint: Endpoint,
): CancelImportContentEntryUseCase {

override fun invoke(cjiUid: Long) {
WorkManager.getInstance(appContext).cancelUniqueWork(
EnqueueContentEntryImportUseCase.uniqueIdFor(endpoint, cjiUid)
)
}
}
Expand Up @@ -37,8 +37,8 @@ class EnqueueImportContentEntryUseCaseAndroid(
.setInputData(jobData)
.build()

val workName = "import-content-entry-${endpoint.url}-$uid"
WorkManager.getInstance(appContext).enqueueUniqueWork(workName,
WorkManager.getInstance(appContext).enqueueUniqueWork(
EnqueueContentEntryImportUseCase.uniqueIdFor(endpoint, uid),
ExistingWorkPolicy.REPLACE, workRequest)
}
}
Expand Down
Expand Up @@ -2,8 +2,8 @@ package com.ustadmobile.core.uri

import android.annotation.SuppressLint
import android.content.Context
import com.ustadmobile.core.util.ext.getFileNameAndSize
import com.ustadmobile.door.DoorUri
import com.ustadmobile.door.ext.getFileName
import kotlinx.io.Source
import kotlinx.io.asSource
import kotlinx.io.buffered
Expand All @@ -16,11 +16,11 @@ class UriHelperAndroid(private val appContext: Context): UriHelper{
}

override suspend fun getFileName(uri: DoorUri): String {
return appContext.contentResolver.getFileName(uri.uri)
return appContext.contentResolver.getFileNameAndSize(uri.uri).first
}

override suspend fun getSize(uri: DoorUri): Long {
return -1
return appContext.contentResolver.getFileNameAndSize(uri.uri).second
}

@SuppressLint("Recycle") //The input stream is closed when the source is closed.
Expand Down
Expand Up @@ -15,6 +15,7 @@ import com.ustadmobile.core.domain.blob.saveandmanifest.SaveLocalUriAsBlobAndMan
import com.ustadmobile.core.domain.blob.savelocaluris.SaveLocalUrisAsBlobsUseCase
import com.ustadmobile.core.domain.cachestoragepath.GetStoragePathForUrlUseCase
import com.ustadmobile.core.domain.cachestoragepath.getLocalUriIfRemote
import com.ustadmobile.core.domain.compress.CompressUseCase
import com.ustadmobile.core.domain.contententry.ContentConstants
import com.ustadmobile.core.domain.validatevideofile.ValidateVideoFileUseCase
import com.ustadmobile.core.io.ext.toDoorUri
Expand Down Expand Up @@ -53,6 +54,7 @@ class VideoContentImporterCommonJvm(
private val getStoragePathForUrlUseCase: GetStoragePathForUrlUseCase,
private val validateVideoFileUseCase: ValidateVideoFileUseCase,
private val mimeTypeHelper: MimeTypeHelper,
private val compressUseCase: CompressUseCase? = null,
) : ContentImporter(endpoint) {


Expand All @@ -76,7 +78,34 @@ class VideoContentImporterCommonJvm(
progressListener: ContentImportProgressListener,
): ContentEntryVersion = withContext(Dispatchers.IO) {
val jobUri = jobItem.requireSourceAsDoorUri()
val localUri = getStoragePathForUrlUseCase.getLocalUriIfRemote(jobUri)
val fromUri = getStoragePathForUrlUseCase.getLocalUriIfRemote(jobUri)

//Get the mime type from the uri to import if possible
// If not, try looking at the original filename (might be needed where using temp import
// files etc.
val fromMimeType = uriHelper.getMimeType(jobUri)
?: jobItem.cjiOriginalFilename?.fileExtensionOrNull()?.let {
mimeTypeHelper.guessByExtension(it)
} ?: throw IllegalStateException("Cannot get mime type")


val compressUseCaseVal = compressUseCase
val (uri, mimeType) = if(compressUseCaseVal != null) {
compressUseCaseVal(
fromUri = fromUri.toString(),
toUri = null,
onProgress = {
progressListener.onProgress(
jobItem.copy(
cjiItemTotal = it.total,
cjiItemProgress = it.completed
)
)
}
).let { DoorUri.parse(it.uri) to it.mimeType }
}else {
fromUri to fromMimeType
}

val contentEntryVersionUid = db.doorPrimaryKeyManager.nextId(ContentEntryVersion.TABLE_ID)
val urlPrefix = createContentUrlPrefix(contentEntryVersionUid)
Expand All @@ -86,14 +115,6 @@ class VideoContentImporterCommonJvm(
val workDir = Path(tmpPath, "video-import-${systemTimeInMillis()}")
fileSystem.createDirectories(workDir)

//Get the mime type from the uri to import if possible
// If not, try looking at the original filename (might be needed where using temp import
// files etc.
val mimeType = uriHelper.getMimeType(jobUri)
?: jobItem.cjiOriginalFilename?.fileExtensionOrNull()?.let {
mimeTypeHelper.guessByExtension(it)
} ?: throw IllegalStateException("Cannot get mime type")

val mediaContentInfo = MediaContentInfo(
sources = listOf(
MediaSource(
Expand All @@ -117,7 +138,7 @@ class VideoContentImporterCommonJvm(
listOf(
SaveLocalUriAsBlobAndManifestUseCase.SaveLocalUriAsBlobAndManifestItem(
blobItem = SaveLocalUrisAsBlobsUseCase.SaveLocalUriAsBlobItem(
localUri = localUri.toString(),
localUri = uri.toString(),
entityUid = contentEntryVersionUid,
tableId = ContentEntryVersion.TABLE_ID,
mimeType = mimeType,
Expand Down
Expand Up @@ -6,5 +6,12 @@ import kotlinx.serialization.Serializable
data class CompressParams(
val maxWidth: Int = 1280,
val maxHeight: Int = 1280,
)
val quality: Int = QUALITY_MEDIUM,
) {
companion object {

const val QUALITY_MEDIUM = 3

}
}

0 comments on commit 28bf6fe

Please sign in to comment.