diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt index 111ed89140..463317b10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.davotoula.lightcompressor.HlsPreparer import com.davotoula.lightcompressor.VideoCodec import com.davotoula.lightcompressor.hls.HlsConfig +import com.davotoula.lightcompressor.hls.HlsLadder import kotlinx.coroutines.CancellationException import java.io.File @@ -49,11 +50,12 @@ object HlsTranscoder { uri: Uri, workDir: File, codec: VideoCodec, + ladder: HlsLadder = HlsLadder.default(), onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> }, ): HlsBundle { workDir.mkdirs() val session = HlsTranscodingSession(workDir, onRenditionProgress) - val config = HlsConfig(codec = codec) + val config = HlsConfig(codec = codec, ladder = ladder) HlsPreparer.start( context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt index d315a11fea..efebabf3b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsLadder import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader import com.vitorpamplona.amethyst.service.uploads.hls.HlsBundle import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline @@ -40,6 +41,7 @@ data class HlsPublishRequest( val contentWarningReason: String, val codec: VideoCodec, val server: ServerName, + val ladder: HlsLadder = HlsLadder.default(), val durationSeconds: Int? = null, ) @@ -56,6 +58,7 @@ class HlsPublishOrchestrator( private val runTranscode: suspend ( workDir: File, codec: VideoCodec, + ladder: HlsLadder, onProgress: (label: String, percent: Int) -> Unit, ) -> HlsBundle, private val buildUploader: (ServerName) -> HlsBlobUploader, @@ -69,7 +72,7 @@ class HlsPublishOrchestrator( try { _state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0) val bundle = - runTranscode(workDir, request.codec) { label, percent -> + runTranscode(workDir, request.codec, request.ladder) { label, percent -> _state.value = HlsPublishState.Transcoding(label, percent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt index 659a124c0c..fbd0b5d7c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -45,13 +45,14 @@ fun createProductionHlsPublishOrchestrator( ): HlsPublishOrchestrator = HlsPublishOrchestrator( _state = state, - runTranscode = { workDir, codec, onProgress -> + runTranscode = { workDir, codec, ladder, onProgress -> val uri = uriProvider() ?: error("No video picked") HlsTranscoder.transcode( context = context, uri = uri, workDir = workDir, codec = codec, + ladder = ladder, onRenditionProgress = onProgress, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index cb51acc81b..8f0414ad61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -189,7 +190,7 @@ private fun IdleBody(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(24.dp)) Button( onClick = { vm.publish(context) }, - enabled = vm.title.isNotBlank() && vm.selectedServer != null, + enabled = vm.title.isNotBlank() && vm.selectedServer != null && vm.selectedRenditionLabels.isNotEmpty(), modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.hls_publish_button)) @@ -375,8 +376,8 @@ private fun FormFields(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(16.dp)) - // Renditions preview (read-only) - RenditionsPreview(vm.sourceMetadata) + // Renditions — user can toggle which rungs to upload + RenditionsCheckboxes(vm) } @Composable @@ -419,51 +420,73 @@ private fun CodecToggle( } @Composable -private fun RenditionsPreview(metadata: HlsSourceMetadata?) { +private fun RenditionsCheckboxes(vm: NewHlsVideoViewModel) { Text( text = stringResource(R.string.hls_renditions_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - if (metadata == null) { - Text( - text = "360p · 540p · 720p · 1080p · 4K", - style = MaterialTheme.typography.bodyMedium, - ) - return - } - val shortSide = minOf(metadata.width, metadata.height) - val ladder = - HlsLadder - .default() - .forSource(shortSide) - .renditions - .map { it.resolution.label } - val skipped = - HlsLadder - .default() - .renditions - .map { it.resolution.label } - .filter { it !in ladder } - Text( - text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.hls_renditions_produce_format, ladder.joinToString(" · ")), - style = MaterialTheme.typography.bodyMedium, - ) - if (skipped.isNotEmpty()) { - Spacer(Modifier.height(2.dp)) + val metadata = vm.sourceMetadata + val sourceShortSide = metadata?.let { minOf(it.width, it.height) } + if (metadata != null) { Text( - text = stringResource(R.string.hls_renditions_skipped_format, skipped.joinToString(", ")), + text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(Modifier.height(8.dp)) + } + + HlsLadder.default().renditions.forEach { rendition -> + val label = rendition.resolution.label + val aboveSource = sourceShortSide != null && rendition.resolution.shortSide > sourceShortSide + val enabled = !aboveSource + val checked = label in vm.selectedRenditionLabels && !aboveSource + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { + vm.selectedRenditionLabels = + if (checked) { + vm.selectedRenditionLabels - label + } else { + vm.selectedRenditionLabels + label + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = { + vm.selectedRenditionLabels = + if (it) vm.selectedRenditionLabels + label else vm.selectedRenditionLabels - label + }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = + if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + val subline = + if (aboveSource) { + stringResource(R.string.hls_rendition_above_source) + } else { + stringResource(R.string.hls_rendition_bitrate_kbps_format, rendition.bitrateKbps) + } + Text( + text = subline, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index c7816c86f4..eaccaa3a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsLadder import com.davotoula.lightcompressor.utils.CompressorUtils import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS @@ -66,6 +67,14 @@ open class NewHlsVideoViewModel : ViewModel() { var draftNoteAfterUpload by mutableStateOf(true) var selectedServer by mutableStateOf(null) + var selectedRenditionLabels by mutableStateOf( + HlsLadder + .default() + .renditions + .map { it.resolution.label } + .toSet(), + ) + private val _state = MutableStateFlow(HlsPublishState.Idle) val state: StateFlow = _state.asStateFlow() @@ -136,8 +145,13 @@ open class NewHlsVideoViewModel : ViewModel() { val server = selectedServer ?: return if (pickedUri == null) return if (title.isBlank()) return + if (selectedRenditionLabels.isEmpty()) return val codec = effectiveCodec(useH265) + val ladder = + HlsLadder( + HlsLadder.default().renditions.filter { it.resolution.label in selectedRenditionLabels }, + ) val request = HlsPublishRequest( title = title, @@ -146,6 +160,7 @@ open class NewHlsVideoViewModel : ViewModel() { contentWarningReason = contentWarningReason, codec = codec, server = server, + ladder = ladder, durationSeconds = sourceMetadata?.durationSeconds, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99c580a522..bc1cf356ba 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2101,6 +2101,8 @@ Source resolution: %1$d×%2$d Will produce: %1$s (%1$s skipped — above source) + %1$d kbps + above source — will be skipped Publish HD video Publishing “%1$s”… Transcoding %1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt index fbce8d2c3e..3fda699b10 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -113,7 +113,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> publishedTemplates += tpl @@ -147,7 +147,7 @@ class HlsPublishOrchestratorTest { orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, onProgress -> + runTranscode = { _, _, _, onProgress -> capturedDuringTranscode += orchestrator.state.value onProgress("360p", 42) capturedDuringTranscode += orchestrator.state.value @@ -181,7 +181,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, + runTranscode = { _, _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, @@ -199,7 +199,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, @@ -219,7 +219,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, @@ -238,7 +238,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> captured += tpl @@ -276,7 +276,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, + runTranscode = { _, _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> captured += tpl @@ -295,7 +295,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> throw RuntimeException("boom") }, + runTranscode = { _, _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } },