Use trimmed video start for thumbnail generation.

This commit is contained in:
Cody Henthorne
2026-05-08 12:56:10 -04:00
committed by Michelle Tang
parent 5e4865be73
commit d1e2fc0423
11 changed files with 44 additions and 19 deletions
@@ -36,6 +36,7 @@ import com.bumptech.glide.request.Request;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import org.signal.core.models.media.TransformProperties;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.core.util.logging.Log;
@@ -608,7 +609,14 @@ public class ThumbnailView extends FrameLayout {
}
private RequestBuilder<Drawable> buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri())))
long videoTrimStartTimeUs = 0;
TransformProperties transformProperties = slide.asAttachment().transformProperties;
if (transformProperties != null && !transformProperties.shouldSkipTransform()) {
videoTrimStartTimeUs = transformProperties.videoTrimStartTimeUs;
}
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri()), videoTrimStartTimeUs))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.transition(withCrossFade()));
@@ -158,9 +158,16 @@ class V2ConversationItemThumbnail @JvmOverloads constructor(
}
if (thumbnailUri != null) {
val transformProperties = thumbnailAttachment.transformProperties
val videoTrimStartTimeUs = if (transformProperties != null && !transformProperties.skipTransform) {
transformProperties.videoTrimStartTimeUs
} else {
0L
}
conversationContext
.requestManager
.load(DecryptableUri(thumbnailUri))
.load(DecryptableUri(thumbnailUri, videoTrimStartTimeUs))
.centerInside()
.dontAnimate()
.override(thumbnailSize.width, thumbnailSize.height)
@@ -11,7 +11,7 @@ import org.signal.glide.common.io.InputStreamFactory
import org.thoughtcrime.securesms.glide.DecryptableStreamFactory
object SignalGlideDependenciesProvider : SignalGlideDependencies.Provider {
override fun getUriInputStreamFactory(uri: Uri): InputStreamFactory {
return DecryptableStreamFactory(uri)
override fun getUriInputStreamFactory(uri: Uri, thumbnailTimeUs: Long): InputStreamFactory {
return DecryptableStreamFactory(uri, thumbnailTimeUs)
}
}
@@ -15,7 +15,8 @@ import java.io.InputStream
* A factory that creates a new [InputStream] for the given [Uri] each time [create] is called.
*/
class DecryptableStreamFactory(
private val uri: Uri
private val uri: Uri,
private val thumbnailTimeUs: Long = 0
) : InputStreamFactory {
companion object {
private val TAG = Log.tag(DecryptableStreamFactory::class)
@@ -23,7 +24,7 @@ class DecryptableStreamFactory(
override fun create(): InputStream {
return try {
DecryptableStreamLocalUriFetcher(AppDependencies.application, uri).loadResource(uri, AppDependencies.application.contentResolver)
DecryptableStreamLocalUriFetcher(AppDependencies.application, uri, thumbnailTimeUs).loadResource(uri, AppDependencies.application.contentResolver)
} catch (e: Exception) {
Log.w(TAG, "Error creating input stream for URI.", e)
throw e
@@ -35,16 +35,19 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
private static final long TOTAL_PIXEL_SIZE_LIMIT = 200_000_000L; // 200 megapixels
private final Context context;
private final long thumbnailTimeUs;
DecryptableStreamLocalUriFetcher(Context context, Uri uri) {
DecryptableStreamLocalUriFetcher(Context context, Uri uri, long thumbnailTimeUs) {
super(context.getContentResolver(), uri);
this.context = context;
this.context = context;
this.thumbnailTimeUs = thumbnailTimeUs;
}
@Override
protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException {
if (MediaUtil.hasVideoThumbnail(context, uri)) {
Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, 1000);
long timeUs = thumbnailTimeUs > 0 ? thumbnailTimeUs : 1000;
Bitmap thumbnail = MediaUtil.getVideoThumbnail(context, uri, timeUs);
if (thumbnail != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity
import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.recipients.Recipient
@@ -339,7 +340,10 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
pagerAdapter.submitMedia(state.selectedMedia)
selectionAdapter.submitList(
state.selectedMedia.map { MediaReviewSelectedItem.Model(it, state.focusedMedia == it) } + MediaReviewAddItem.Model
state.selectedMedia.map {
val trimStartTimeUs = (state.editorStateMap[it.uri] as? VideoTrimData)?.startTimeUs ?: 0L
MediaReviewSelectedItem.Model(it, state.focusedMedia == it, trimStartTimeUs)
} + MediaReviewAddItem.Model
)
presentSendButton(readyToSend, state.sendType, state.recipient)
@@ -20,13 +20,13 @@ object MediaReviewSelectedItem {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_review_selected_item))
}
class Model(val media: Media, val isSelected: Boolean) : MappingModel<Model> {
class Model(val media: Media, val isSelected: Boolean, val videoTrimStartTimeUs: Long = 0) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return media == newItem.media
}
override fun areContentsTheSame(newItem: Model): Boolean {
return media == newItem.media && isSelected == newItem.isSelected
return media == newItem.media && isSelected == newItem.isSelected && videoTrimStartTimeUs == newItem.videoTrimStartTimeUs
}
}
@@ -38,7 +38,7 @@ object MediaReviewSelectedItem {
override fun bind(model: Model) {
Glide.with(imageView)
.load(DecryptableUri(model.media.uri))
.load(DecryptableUri(model.media.uri, model.videoTrimStartTimeUs))
.centerCrop()
.into(imageView)
@@ -29,12 +29,12 @@ object SignalGlideDependencies {
val application: Application
get() = _application
fun getUriInputStreamFactory(uri: Uri): InputStreamFactory = _provider.getUriInputStreamFactory(uri)
fun getUriInputStreamFactory(uri: Uri, thumbnailTimeUs: Long = 0): InputStreamFactory = _provider.getUriInputStreamFactory(uri, thumbnailTimeUs)
interface Provider {
/**
* A factory which can create an [java.io.InputStream] from a given [Uri]
* A factory which can create an [java.io.InputStream] from a given [Uri]. For videos, [thumbnailTimeUs] specifies the frame time to extract.
*/
fun getUriInputStreamFactory(uri: Uri): InputStreamFactory
fun getUriInputStreamFactory(uri: Uri, thumbnailTimeUs: Long = 0): InputStreamFactory
}
}
@@ -17,7 +17,8 @@ import java.io.InputStream
interface InputStreamFactory {
companion object {
@JvmStatic
fun build(uri: Uri): InputStreamFactory = SignalGlideDependencies.getUriInputStreamFactory(uri)
@JvmOverloads
fun build(uri: Uri, thumbnailTimeUs: Long = 0): InputStreamFactory = SignalGlideDependencies.getUriInputStreamFactory(uri, thumbnailTimeUs)
@JvmStatic
fun build(file: File): InputStreamFactory = FileInputStreamFactory(file)
@@ -9,8 +9,9 @@ import android.net.Uri
import com.bumptech.glide.load.Key
import java.security.MessageDigest
data class DecryptableUri(val uri: Uri) : Key {
data class DecryptableUri @JvmOverloads constructor(val uri: Uri, val thumbnailTimeUs: Long = 0) : Key {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(uri.toString().toByteArray())
messageDigest.update(thumbnailTimeUs.toString().toByteArray())
}
}
@@ -22,7 +22,7 @@ class DecryptableUriStreamFetcher(
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStreamFactory>) {
try {
callback.onDataReady(InputStreamFactory.build(decryptableUri.uri))
callback.onDataReady(InputStreamFactory.build(decryptableUri.uri, decryptableUri.thumbnailTimeUs))
} catch (e: Exception) {
callback.onLoadFailed(e)
}