-
Notifications
You must be signed in to change notification settings - Fork 36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Bug: ZoomableAsyncImage Ignores Transformations #131
Comments
Supporting bitmap transformations is sadly going to be impossible because What kind of transformations are you using? Your
You're right that |
Hi Saket,
The My goal is to apply the effect per tile so that the same level of detail is preserved as the user zooms in. I also attempted this using Thanks you! |
Got it.
@Composable
fun ZoomableImageSource.foo(): ZoomableImageSource {
val delegateSource = this
return remember(delegateSource) {
object : ZoomableImageSource {
@Composable
override fun resolve(canvasSize: Flow<Size>): ZoomableImageSource.ResolveResult {
val result = delegateSource.resolve(canvasSize)
return result.copy(
delegate = when (val delegate = result.delegate) {
is ZoomableImageSource.SubSamplingDelegate -> {
ZoomableImageSource.SubSamplingDelegate(
source = Foo(delegate.source),
imageOptions = delegate.imageOptions,
)
}
else -> delegate
}
)
}
}
}
}
class Foo(
private val delegate: SubSamplingImageSource,
) : SubSamplingImageSource by delegate {
override suspend fun decoder(): ImageRegionDecoder.Factory {
return ImageRegionDecoder.Factory { params ->
FooImageDecoder(
delegate = delegate.decoder().create(params),
)
}
}
}
class FooImageDecoder(
private val delegate: ImageRegionDecoder,
) : ImageRegionDecoder by delegate {
override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult {
val result = delegate.decodeRegion(region, sampleSize)
return ImageRegionDecoder.DecodeResult(
painter = TODO("decorate result.painter and apply effects"),
hasUltraHdrContent = result.hasUltraHdrContent,
)
}
} Does this make sense? You can skip step 1 if you are using |
I tried implementing a custom image region decoder, however I couldn't get it to work. It is difficult to even extract the original bitmap from the painter. Is it possible to intercept the tile bitmap, so effects could be applied to it before it is rendered? Thank you |
Ah right, you can't extract bitmaps out of
Sadly no because What kind of images are you displaying? Can you create your own |
The app is displaying pictures from user's device, usually normal pictures from camera. I tried to come up with a custom ImageRegionDecoder, but for some reason it still displays the original picture without the desired effect. import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.saket.telephoto.subsamplingimage.ImageBitmapOptions
import me.saket.telephoto.subsamplingimage.internal.ImageRegionDecoder
import java.io.InputStream
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.Flow
import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
import me.saket.telephoto.zoomable.ZoomableImage
import me.saket.telephoto.zoomable.ZoomableImageSource
import me.saket.telephoto.zoomable.coil3.coil
import me.saket.telephoto.zoomable.copy
/**
* A [SubSamplingImageSource] that always returns custom FilmSimulatedImageDecoder.Factory.
*/
class FilmSimulatedSubSamplingImageSource(
private val delegate: SubSamplingImageSource,
private val factory: FilmSimulatedImageDecoder.Factory
) : SubSamplingImageSource by delegate {
override suspend fun decoder(): ImageRegionDecoder.Factory = factory
}
/**
* A custom ImageRegionDecoder that intercepts tile decoding and applies film simulation.
*/
class FilmSimulatedImageDecoder(
private val delegate: BitmapRegionDecoder
) : ImageRegionDecoder {
// Provide the full image size as required.
override val imageSize: IntSize = IntSize(delegate.width, delegate.height)
/**
* Decodes a given region. Converts [IntRect] (Compose) to [Rect] (Android),
* decodes the bitmap region with the provided sample size, applies the film simulation,
* and returns a [DecodeResult] wrapping a Compose [Painter].
*/
override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult {
// Convert Compose IntRect to Android Rect.
val androidRect = Rect(region.left, region.top, region.right, region.bottom)
val bitmap = withContext(Dispatchers.IO) {
delegate.decodeRegion(
androidRect,
BitmapFactory.Options().apply { inSampleSize = sampleSize }
)
}
// Apply the effect.
val simulatedBitmap = myVintageEffect(
bitmap = bitmap
)
val painter = BitmapPainter(simulatedBitmap.asImageBitmap())
return ImageRegionDecoder.DecodeResult(
painter = painter,
hasUltraHdrContent = false
)
}
/**
* Releases resources.
*/
override fun close() {
delegate.recycle()
}
class Factory(
private val inputStreamProvider: (ImageBitmapOptions) -> InputStream
) : ImageRegionDecoder.Factory {
override suspend fun create(params: ImageRegionDecoder.FactoryParams): ImageRegionDecoder {
val inputStream = inputStreamProvider(params.imageOptions)
val regionDecoder = withContext(Dispatchers.IO) {
BitmapRegionDecoder.newInstance(inputStream, false)
}
return FilmSimulatedImageDecoder(
requireNotNull(regionDecoder) { "Unable to create BitmapRegionDecoder" }
)
}
}
}
@Composable
fun ZoomableImageSource.filmSimulated(
factory: FilmSimulatedImageDecoder.Factory
): ZoomableImageSource {
val delegateSource = this
return remember(delegateSource) {
object : ZoomableImageSource {
@Composable
override fun resolve(canvasSize: Flow<Size>): ZoomableImageSource.ResolveResult {
val result = delegateSource.resolve(canvasSize)
return result.copy(
delegate = when (val delegate = result.delegate) {
is ZoomableImageSource.SubSamplingDelegate -> {
// Wrap the original SubSamplingImageSource with the film simulation decorator.
ZoomableImageSource.SubSamplingDelegate(
source = FilmSimulatedSubSamplingImageSource(delegate.source, factory),
imageOptions = delegate.imageOptions
)
}
else -> delegate
}
)
}
}
}
}
@Composable
fun FilmSimulatedZoomableImage(
uri: Uri,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
// Create Telephoto's default image source from the URI.
val originalSource = ZoomableImageSource.coil(model = uri)
// Create the custom decoder factory.
val filmFactory = FilmSimulatedImageDecoder.Factory { options ->
// Open an InputStream from the URI.
context.contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("Unable to open stream for uri: $uri")
}
val filmSimulatedSource = originalSource.filmSimulated(filmFactory)
ZoomableImage(
image = filmSimulatedSource,
modifier = modifier,
contentDescription = "Test"
)
}
fun myVintageEffect(bitmap: Bitmap): Bitmap {
// For demonstration, perform a simple grayscale conversion.
val width = bitmap.width
val height = bitmap.height
val config = bitmap.config ?: Bitmap.Config.ARGB_8888
val output = Bitmap.createBitmap(width, height, config)
for (x in 0 until width) {
for (y in 0 until height) {
val pixel = bitmap.getPixel(x, y)
val r = (pixel shr 16) and 0xff
val g = (pixel shr 8) and 0xff
val b = pixel and 0xff
val gray = ((r + g + b) / 3)
val newPixel = (0xff shl 24) or (gray shl 16) or (gray shl 8) or gray
output.setPixel(x, y, newPixel)
}
}
return output
}
/*
How to test it:
FilmSimulatedZoomableImage(
uri = imageUri,
modifier = Modifier.fillMaxSize()
)
*/ Do you think it is achievable with the current state of Telephoto? If not, do you have any suggestions? |
When using
ZoomableAsyncImage
, image transformations applied viaImageRequest.Builder().transformations()
are ignored, and the original image is displayed instead. However, using the same request with Coil3AsyncImage
correctly applies the transformations.Expected Behavior
ZoomableAsyncImage
should respect and apply transformations set in ImageRequest, just likeAsyncImage
does.Steps to Reproduce
Also, a simplified version of MyVintageEffect for testing:
Actual Behavior
ZoomableAsyncImage
displays the original image, ignoring transformations.AsyncImage
correctly applies the transformation.Environment
Telephoto version: 0.15.0
Android version: 14
Additional Context
Since
ZoomableAsyncImage
internally integrates with Coil, it should respect transformations likeAsyncImage
does. Let me know if this behavior is intentional or if there’s a workaround to ensure transformations apply correctly.The text was updated successfully, but these errors were encountered: