mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(hls): checkboxes to pick which renditions to upload
Users hit a server upload failure and asked for the ability to skip the biggest renditions. The LightCompressor HlsLadder API already supports filtering — this commit wires that through the full stack: - HlsTranscoder.transcode() now accepts an HlsLadder parameter defaulting to HlsLadder.default(), and passes it into HlsConfig. - HlsPublishRequest gains a ladder field. - HlsPublishOrchestrator.runTranscode callback now takes the ladder as a fourth parameter; the orchestrator forwards request.ladder into it. - The production factory captures the ladder on each publish. - NewHlsVideoViewModel tracks selectedRenditionLabels as a mutable Set defaulting to all five default rungs. publish() builds an HlsLadder from that set by filtering HlsLadder.default().renditions and refuses to publish when the set is empty. - NewHlsVideoScreen replaces the read-only renditions preview with a RenditionsCheckboxes composable. Each default rung renders as a Checkbox + label + bitrate; rungs above the detected source short side are disabled with an "above source — will be skipped" subline so the user cannot accidentally select a rendition the library would drop anyway. - Publish button gates on selectedRenditionLabels.isNotEmpty() so an empty selection never fires the pipeline. Tests updated for the new runTranscode signature. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
453bdab202
commit
a4787c5fdc
+3
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+60
-37
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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<ServerName?>(null)
|
||||
|
||||
var selectedRenditionLabels by mutableStateOf(
|
||||
HlsLadder
|
||||
.default()
|
||||
.renditions
|
||||
.map { it.resolution.label }
|
||||
.toSet(),
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle)
|
||||
val state: StateFlow<HlsPublishState> = _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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2101,6 +2101,8 @@
|
||||
<string name="hls_renditions_source_format">Source resolution: %1$d×%2$d</string>
|
||||
<string name="hls_renditions_produce_format">Will produce: %1$s</string>
|
||||
<string name="hls_renditions_skipped_format">(%1$s skipped — above source)</string>
|
||||
<string name="hls_rendition_bitrate_kbps_format">%1$d kbps</string>
|
||||
<string name="hls_rendition_above_source">above source — will be skipped</string>
|
||||
<string name="hls_publish_button">Publish HD video</string>
|
||||
<string name="hls_publishing_header_format">Publishing “%1$s”…</string>
|
||||
<string name="hls_state_transcoding_format">Transcoding %1$s</string>
|
||||
|
||||
+8
-8
@@ -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() } },
|
||||
|
||||
Reference in New Issue
Block a user