Skip to content

Commit

Permalink
Sprite sheet (#777)
Browse files Browse the repository at this point in the history
Co-authored-by: Gaëtan Muller <[email protected]>
Co-authored-by: Gaëtan Muller <[email protected]>
  • Loading branch information
3 people authored Nov 22, 2024
1 parent 786c546 commit 19f9f03
Show file tree
Hide file tree
Showing 20 changed files with 541 additions and 11 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ androidx-media3-cast = { group = "androidx.media3", name = "media3-cast", versio
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "androidx-media3" }
androidx-media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "androidx-media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "androidx-media3" }
androidx-media3-decoder = { group = "androidx.media3", name = "media3-decoder", version.ref = "androidx-media3" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "androidx-media3" }
androidx-media3-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "androidx-media3" }
Expand Down
1 change: 1 addition & 0 deletions pillarbox-core-business/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies {
api(libs.androidx.media3.common)
api(libs.androidx.media3.datasource)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.decoder)
api(libs.androidx.media3.exoplayer)
implementation(libs.guava)
runtimeOnly(libs.kotlinx.coroutines.android)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import kotlinx.serialization.Serializable
* @property timeIntervalList List of time intervals relevant to the chapter.
* @property validFrom The [Instant] when the [Chapter] becomes valid.
* @property validTo The [Instant] until when the [Chapter] is valid.
* @property spriteSheet The [SpriteSheet] information if available.
*/
@Serializable
data class Chapter(
Expand All @@ -51,6 +52,7 @@ data class Chapter(
val timeIntervalList: List<TimeInterval>? = null,
val validFrom: Instant? = null,
val validTo: Instant? = null,
val spriteSheet: SpriteSheet? = null,
) : DataWithAnalytics {
/**
* Indicates whether this represents a full-length chapter.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.core.business.integrationlayer.data

import kotlinx.serialization.Serializable

/**
* Represents a sprite sheet containing multiple thumbnail images arranged in a grid.
*
* @property urn The URN of the media.
* @property rows The number of rows in the sprite sheet.
* @property columns The number of columns in the sprite sheet.
* @property thumbnailHeight The height of each thumbnail image, in pixels.
* @property thumbnailWidth The width of each thumbnail image, in pixels.
* @property interval The interval between two thumbnail images, in milliseconds.
* @property url The URL of the sprite sheet image.
*/
@Serializable
data class SpriteSheet(
val urn: String,
val rows: Int,
val columns: Int,
val thumbnailHeight: Int,
val thumbnailWidth: Int,
val interval: Long,
val url: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.datasource.DataSource.Factory
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import ch.srgssr.pillarbox.core.business.HttpResultException
import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenDataSource
import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenProvider
Expand Down Expand Up @@ -99,6 +100,7 @@ class SRGAssetLoader internal constructor(
private val customTrackerData: (MutableMediaItemTrackerData.(Resource, Chapter, MediaComposition) -> Unit)?,
private val customMediaMetadata: (suspend MediaMetadata.Builder.(MediaMetadata, Chapter, MediaComposition) -> Unit)?,
private val resourceSelector: ResourceSelector,
private val spriteSheetLoader: SpriteSheetLoader,
) : AssetLoader(
mediaSourceFactory = DefaultMediaSourceFactory(AkamaiTokenDataSource.Factory(akamaiTokenProvider, dataSourceFactory))
) {
Expand Down Expand Up @@ -157,8 +159,12 @@ class SRGAssetLoader internal constructor(
.setDrmConfiguration(fillDrmConfiguration(resource))
.setUri(uri)
.build()
val contentMediaSource = mediaSourceFactory.createMediaSource(loadingMediaItem)
val mediaSource = chapter.spriteSheet?.let {
MergingMediaSource(contentMediaSource, SpriteSheetMediaSource(it, loadingMediaItem, spriteSheetLoader))
} ?: contentMediaSource
return Asset(
mediaSource = mediaSourceFactory.createMediaSource(loadingMediaItem),
mediaSource = mediaSource,
trackersData = trackerData.toMediaItemTrackerData(),
mediaMetadata = mediaItem.mediaMetadata.buildUpon().apply {
defaultMediaMetadata.invoke(this, mediaItem.mediaMetadata, chapter, result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package ch.srgssr.pillarbox.core.business.source

import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.VisibleForTesting
import androidx.media3.common.MediaMetadata
import androidx.media3.datasource.DataSource.Factory
Expand All @@ -16,6 +17,7 @@ import ch.srgssr.pillarbox.core.business.integrationlayer.ResourceSelector
import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter
import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition
import ch.srgssr.pillarbox.core.business.integrationlayer.data.Resource
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService
import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService
import ch.srgssr.pillarbox.core.business.tracker.commandersact.CommandersActTracker
Expand All @@ -37,6 +39,7 @@ import kotlinx.coroutines.Dispatchers
* - Setting an HTTP client for network requests.
* - Injecting custom data into media item tracker data.
* - Overriding the default media metadata.
* - Providing a custom [Bitmap] loader for sprite sheet.
*
* @param context The Android [Context].
*/
Expand All @@ -50,6 +53,7 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
private var commanderActTrackerFactory: MediaItemTracker.Factory<CommandersActTracker.Data> =
CommandersActTracker.Factory(SRGAnalytics.commandersAct, Dispatchers.Default)
private var comscoreTrackerFactory: MediaItemTracker.Factory<ComScoreTracker.Data> = ComScoreTracker.Factory()
private var spriteSheetLoader: SpriteSheetLoader = SpriteSheetLoader.Default()

@VisibleForTesting
internal fun commanderActTrackerFactory(commanderActTrackerFactory: MediaItemTracker.Factory<CommandersActTracker.Data>) {
Expand Down Expand Up @@ -142,6 +146,25 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
mediaMetadataOverride = block
}

/**
* Sets the [SpriteSheetLoader] to be used to load a [Bitmap] from a [SpriteSheet].
*
* **Example**
*
* ```kotlin
* val srgAssetLoader = SRGAssetLoader(context) {
* spriteSheetLoader { spriteSheet, onComplete ->
* onComplete(loadBitmap(spriteSheet.url))
* }
* }
* ```
*
* @param spriteSheetLoader The [SpriteSheetLoader] instance to use.
*/
fun spriteSheetLoader(spriteSheetLoader: SpriteSheetLoader) {
this.spriteSheetLoader = spriteSheetLoader
}

internal fun create(): SRGAssetLoader {
return SRGAssetLoader(
akamaiTokenProvider = akamaiTokenProvider,
Expand All @@ -152,6 +175,7 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
comscoreTrackerFactory = comscoreTrackerFactory,
mediaCompositionService = mediaCompositionService,
resourceSelector = ResourceSelector(),
spriteSheetLoader = spriteSheetLoader
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.core.business.source

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.net.URL

/**
* Load a [Bitmap] from a [SpriteSheet].
*
* This interface allows integrators to use their own implementation to load [Bitmap]s using an external library like
* [Glide](https://bumptech.github.io/glide/), [Coil](https://coil-kt.github.io/coil/), ...
*/
fun interface SpriteSheetLoader {
/**
* Load sprite sheet
*
* @param spriteSheet The [SpriteSheet] to load the [Bitmap] from.
* @param onComplete The callback to call when the [Bitmap] has been loaded. Passing `null` means that the [Bitmap] could not be loaded.
*/
fun loadSpriteSheet(spriteSheet: SpriteSheet, onComplete: (Bitmap?) -> Unit)

/**
* Default
*
* @param dispatcher The [CoroutineDispatcher] to use for loading the sprite sheet. Should not be on the main thread.
*/
class Default(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) : SpriteSheetLoader {
override fun loadSpriteSheet(spriteSheet: SpriteSheet, onComplete: (Bitmap?) -> Unit) {
MainScope().launch(dispatcher) {
URL(spriteSheet.url).openStream().use {
onComplete(BitmapFactory.decodeStream(it))
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.core.business.source

import android.graphics.Bitmap
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackGroup
import androidx.media3.decoder.DecoderInputBuffer
import androidx.media3.exoplayer.FormatHolder
import androidx.media3.exoplayer.LoadingInfo
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.MediaPeriod
import androidx.media3.exoplayer.source.SampleStream
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds

/**
* A [MediaPeriod] that loads a [Bitmap] and pass it to a [SampleStream].
*/
internal class SpriteSheetMediaPeriod(
private val spriteSheet: SpriteSheet,
private val spriteSheetLoader: SpriteSheetLoader,
) : MediaPeriod {
private var bitmap: Bitmap? = null
private val isLoading = AtomicBoolean(true)
private val format = Format.Builder()
.setId("SpriteSheet")
.setFrameRate(1f / spriteSheet.interval.milliseconds.inWholeSeconds)
.setCustomData(spriteSheet)
.setRoleFlags(C.ROLE_FLAG_MAIN)
.setContainerMimeType(MimeTypes.IMAGE_JPEG)
.setSampleMimeType(MimeTypes.IMAGE_JPEG)
.build()
private val tracks = TrackGroupArray(TrackGroup("sprite-sheet-srg", format))
private var positionUs = 0L

override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) {
callback.onPrepared(this)
this.positionUs = positionUs
isLoading.set(true)
spriteSheetLoader.loadSpriteSheet(spriteSheet) { bitmap ->
this.bitmap = bitmap
isLoading.set(false)
}
}

fun releasePeriod() {
bitmap?.recycle()
bitmap = null
}

override fun selectTracks(
selections: Array<out ExoTrackSelection?>,
mayRetainStreamFlags: BooleanArray,
streams: Array<SampleStream?>,
streamResetFlags: BooleanArray,
positionUs: Long
): Long {
this.positionUs = getAdjustedSeekPositionUs(positionUs, SeekParameters.DEFAULT)
for (i in selections.indices) {
if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) {
streams[i] = null
}
if (streams[i] == null && selections[i] != null) {
val stream = SpriteSheetSampleStream()
streams[i] = stream
streamResetFlags[i] = true
}
}
return positionUs
}

override fun getTrackGroups(): TrackGroupArray {
return tracks
}

override fun getBufferedPositionUs(): Long {
return C.TIME_UNSET
}

override fun getNextLoadPositionUs(): Long {
return C.TIME_UNSET
}

override fun continueLoading(loadingInfo: LoadingInfo): Boolean {
return isLoading()
}

override fun isLoading(): Boolean {
return isLoading.get()
}

override fun reevaluateBuffer(positionUs: Long) = Unit

override fun maybeThrowPrepareError() = Unit

override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) = Unit

override fun readDiscontinuity(): Long {
return C.TIME_UNSET
}

override fun seekToUs(positionUs: Long): Long {
this.positionUs = positionUs
return positionUs
}

override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long {
val intervalUs = spriteSheet.interval.milliseconds.inWholeMicroseconds
return (positionUs / intervalUs) * intervalUs
}

internal inner class SpriteSheetSampleStream : SampleStream {

override fun isReady(): Boolean {
return !isLoading()
}

override fun maybeThrowError() {
if (bitmap == null && !isLoading.get()) {
throw IOException("Can't decode ${spriteSheet.url}")
}
}

@Suppress("ReturnCount")
override fun readData(formatHolder: FormatHolder, buffer: DecoderInputBuffer, readFlags: Int): Int {
if ((readFlags and SampleStream.FLAG_REQUIRE_FORMAT) != 0) {
formatHolder.format = tracks[0].getFormat(0)
return C.RESULT_FORMAT_READ
}

if (isLoading.get()) {
return C.RESULT_NOTHING_READ
}

val intervalUs = spriteSheet.interval.milliseconds.inWholeMicroseconds
val tileIndex = positionUs / intervalUs
buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME)
buffer.timeUs = positionUs
bitmap?.let { bitmap ->
val data = cropTileFromImageGrid(bitmap, max((tileIndex.toInt() - 1), 0))
buffer.ensureSpaceForWrite(data.size)
buffer.data?.put(data, /* offset= */0, data.size)
}
return C.RESULT_BUFFER_READ
}

override fun skipData(positionUs: Long): Int {
return 0
}

private fun cropTileFromImageGrid(bitmap: Bitmap, tileIndex: Int): ByteArray {
val tileWidth: Int = spriteSheet.thumbnailWidth
val tileHeight: Int = spriteSheet.thumbnailHeight
val tileStartXCoordinate: Int = tileWidth * (tileIndex % spriteSheet.columns)
val tileStartYCoordinate: Int = tileHeight * (tileIndex / spriteSheet.columns)
val tile = Bitmap.createBitmap(bitmap, tileStartXCoordinate, tileStartYCoordinate, tileWidth, tileHeight)
return bitmapToByteArray(tile)
}
}

private companion object {
private const val MAX_QUALITY = 100

private fun bitmapToByteArray(
bitmap: Bitmap,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
quality: Int = MAX_QUALITY
): ByteArray {
return ByteArrayOutputStream().use {
bitmap.compress(format, quality, it)
it.toByteArray()
}
}
}
}
Loading

0 comments on commit 19f9f03

Please sign in to comment.