JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
16.33k stars 1.18k forks source link

Support for animated gif images. #153

Closed sureshg closed 2 years ago

sureshg commented 3 years ago

Trying to display the following animated gif image displays only the first frame because it supports only the Bitmap.

Image(
     bitmap = imageFromResource("lottie-sample.gif"),
     modifier = Modifier.align(Alignment.BottomCenter).preferredSize(100.dp,100.dp)
)

lottie-sample

olonho commented 3 years ago

Not sure if animated GIFs work even on Android. Maybe there's third-party library doing the same?

jimgoog commented 3 years ago

it's worth noting that gifs are hugely bandwidth/latency inefficient (https://rigor.com/blog/optimizing-animated-gifs-with-html5-video/) which becomes a much bigger consideration for mobile devices.

For an animation like this one in particular, it might be worth generating/drawing the bubble explosion directly rather than having a pre-generated resource file (something like https://github.com/adibfara/composeclock). That would allow you to programmatically control things like animation velocity, number of bubbles, etc. It would also allow you to have an animation that could run continuously/forever without jumping, one that visually doesn't ever repeat, etc. Just food for thought.

Dominaezzz commented 3 years ago

This seems interesting, I'll try and make a sample of this.

Dominaezzz commented 3 years ago

This is a sample of naive GIF rendering with compose.

https://gist.github.com/Dominaezzz/cd51f8821162a149ee2a5fb69a702e7f

(Can PR a sample if maintainers are interested)

sureshg commented 3 years ago

@Dominaezzz awesome.. Compiling it on JDK 15 gives the following error even though JDK has the java.xml module

Unresolved reference: NodeList

Should I explicitly add the XML API dependencies for import org.w3c.dom.* ?

Dominaezzz commented 3 years ago

Ha, I guess so, I ran it with JDK 11 and the IntelliJ compose project setup and it just worked.

sureshg commented 3 years ago

It's really weird that only fully qualified NodeList name is working with the compose plugin. Here is the Gradle compilation logs

2020-12-03T00:20:05.814-0800 [DEBUG] [org.gradle.api.Task] v: Configuring the compilation environment
2020-12-03T00:20:05.814-0800 [DEBUG] [org.gradle.api.Task] v: Loading modules: [java.se, jdk.accessibility, jdk.attach, jdk.compiler, jdk.dynalink, jdk.httpserver, jdk.incubator.foreign, jdk.jartool, jdk.javadoc, jdk.jconsole, jdk.jdi, jdk.jfr, jdk.jshell, jdk.jsobject, jdk.management, jdk.management.jfr, jdk.net, jdk.nio.mapmode, jdk.sctp, jdk.security.auth, jdk.security.jgss, jdk.unsupported, jdk.unsupported.desktop, jdk.xml.dom, java.base, java.compiler, java.datatransfer, java.desktop, java.xml, java.instrument, java.logging, java.management, java.management.rmi, java.rmi, java.naming, java.net.http, java.prefs, java.scripting, java.security.jgss, java.security.sasl, java.sql, java.transaction.xa, java.sql.rowset, java.xml.crypto, jdk.internal.jvmstat, jdk.management.agent, jdk.jdwp.agent, jdk.internal.ed, jdk.internal.le, jdk.internal.opt]
2020-12-03T00:20:05.814-0800 [ERROR] [org.gradle.api.Task] e: /Users/sgopal1/code/compose-desktop-sample/src/main/kotlin/dev/suresh/gif/AnimatedGif.kt: (221, 21): Unresolved reference: NodeList
2020-12-03T00:20:05.814-0800 [ERROR] [org.gradle.api.Task] e: /Users/sgopal1/code/compose-desktop-sample/src/main/kotlin/dev/suresh/gif/AnimatedGif.kt: (233, 21): Unresolved reference: NodeList
2020-12-03T00:20:05.815-0800 [DEBUG] [sun.rmi.transport.tcp] Execution worker for ':': reuse connection

From the logs, it's clear that JDK has java.xml module in the compilation environment but somehow Kotlin compile step is failing. The issue goes away when we have the fully qualified class name in the code (private fun org.w3c.dom.NodeList.asSequence())

JDK         : 16-loom+9-316
Gradle      : 6.8-rc-1
Kotlin      : 1.4.20
Compose     : 0.3.0-build133
igordmn commented 3 years ago

Example how to show gif using org.jetbrains.skija.Codec: (see more optimized version in the next comment)

import androidx.compose.desktop.Window
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import org.jetbrains.skija.Bitmap
import org.jetbrains.skija.Canvas
import org.jetbrains.skija.Codec
import org.jetbrains.skija.Data
import java.net.URL

fun main() = Window {
    val codec = remember {
        val bytes = URL(
            "https://raw.githubusercontent.com/JetBrains/skija/ccf303ebcf926e5ef000fc42d1a6b5b7f1e0b2b5/examples/scenes/images/codecs/animated.gif"
        ).readBytes()
        Codec.makeFromData(Data.makeFromBytes(bytes))
    }

    GifAnimation(codec, Modifier.size(100.dp))
}

@Composable
fun GifAnimation(codec: Codec, modifier: Modifier) {
    val animation = remember(codec) { GifAnimation(codec) }

    LaunchedEffect(animation) {
        while (true) {
            withFrameNanos {
                animation.update(it)
            }
        }
    }

    Canvas(modifier) {
        drawIntoCanvas {
            animation.draw(it.nativeCanvas)
        }
    }
}

private class GifAnimation(private val codec: Codec) {
    private val bitmap = Bitmap().apply {
        allocPixels(codec.imageInfo)
    }
    private val durations = codec.framesInfo.map { it.duration * 1_000_000 }
    private val totalDuration = durations.sum()

    private var startTime = -1L
    private var frame by mutableStateOf(0)

    fun update(nanoTime: Long) {
        if (startTime == -1L) {
            startTime = nanoTime
        }

        frame = frameOf(time = (nanoTime - startTime) % totalDuration)
    }

    // WARNING: it is not optimal
    private fun frameOf(time: Long): Int {
        var t = 0
        for (frame in durations.indices) {
            t += durations[frame]
            if (t >= time) return frame
        }
        error("Unexpected")
    }

    fun draw(canvas: Canvas) {
        codec.readPixels(bitmap, frame)
        canvas.drawBitmap(bitmap, 0f, 0f)
    }
}
olonho commented 3 years ago

A bit optimized version:

import androidx.compose.desktop.Window
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import org.jetbrains.skija.Bitmap
import org.jetbrains.skija.Canvas
import org.jetbrains.skija.Codec
import org.jetbrains.skija.Data
import java.net.URL
fun main() = Window {
    val codec = remember {
        val bytes = URL(
            "https://raw.githubusercontent.com/JetBrains/skija/ccf303ebcf926e5ef000fc42d1a6b5b7f1e0b2b5/examples/scenes/images/codecs/animated.gif"
        ).readBytes()
        Codec.makeFromData(Data.makeFromBytes(bytes))
    }
    GifAnimation(codec, Modifier.size(100.dp))
}
@Composable
fun GifAnimation(codec: Codec, modifier: Modifier) {
    val animation = remember(codec) { GifAnimation(codec) }
    LaunchedEffect(animation) {
        while (true) {
            withFrameNanos {
                animation.update(it)
            }
        }
    }
    Canvas(modifier) {
        drawIntoCanvas {
            animation.draw(it.nativeCanvas)
        }
    }
}
private class GifAnimation(private val codec: Codec) {
    private val bitmap = Bitmap().apply {
        allocPixels(codec.imageInfo)
    }
    private val durations = codec.framesInfo.map { it.duration * 1_000_000 }
    private val totalDuration = durations.sum()
    private var startTime = -1L
    private var lastFrame = 0
    private var lastDuration = 0L
    private var frame by mutableStateOf(0)
    fun update(nanoTime: Long) {
        if (startTime == -1L) {
            startTime = nanoTime
        }
        frame = frameOf(time = (nanoTime - startTime) % totalDuration)
    }
    private fun frameOf(time: Long): Int {
        var t = lastDuration
        for (frame in lastFrame until durations.size) {
            if (t >= time) {
                lastFrame = frame
                lastDuration = t
                return frame
            }
            t += durations[frame]
        }
        lastFrame = 0
        lastDuration = 0L
        return 0
    }
    fun draw(canvas: Canvas) {
        codec.readPixels(bitmap, frame)
        canvas.drawBitmap(bitmap, 0f, 0f)
    }
}
samuelprince77 commented 3 years ago

@olonho This approach seems to fail on most lottie generated gifs. Most either play a single frame or the first few frames before stopping, not really sure why. An example gif Brain gif. The approach posted by Dominaezzz seems to work well though.

sureshg commented 3 years ago

@samuelprince77 the issue is because of Int overflow happening in this line

private val durations = codec.framesInfo.map { it.duration * 1_000_000 }
private val totalDuration = durations.sum() // int overflow here

The return type of duration is List<Int>, so that the totalDuration will become int. Changing it to 1_000_000L will fix the issue.

sureshg commented 3 years ago

By the way, canvas.drawBitmap(bitmap, 0f, 0f) has removed in the latest release (0.4.0-build198). The modified draw() function is,

 fun draw(canvas: Canvas) {
    codec.readPixels(bitMap, currFrame)
    canvas.drawImage(Image.makeFromBitmap(bitMap), 0f, 0f)
 }
whitescent commented 3 years ago

It would be great if the Coil and Glide in accompanist could be used in Compose-Jb. Anyone to test it?

KotlinGeekDev commented 3 years ago

Hello @Nthily . I think that Accompanist is Android-only. I don't there is desktop(or multiplatform) support yet.

Dominaezzz commented 3 years ago

Slightly shorter.

import androidx.compose.animation.core.*
import androidx.compose.desktop.Window
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.dp
import org.jetbrains.skija.*
import java.net.URL

fun main() = Window {
    val codec = remember {
        val bytes = URL(
            "https://raw.githubusercontent.com/JetBrains/skija/ccf303ebcf926e5ef000fc42d1a6b5b7f1e0b2b5/examples/scenes/images/codecs/animated.gif"
        ).readBytes()
        Codec.makeFromData(Data.makeFromBytes(bytes))
    }
    GifAnimation(codec, Modifier.size(100.dp))
}

@Composable
fun GifAnimation(codec: Codec, modifier: Modifier) {
    val transition = rememberInfiniteTransition()
    val frameIndex by transition.animateValue(
        initialValue = 0,
        targetValue = codec.frameCount - 1,
        Int.VectorConverter,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 0
                for ((index, frame) in codec.framesInfo.withIndex()) {
                    index at durationMillis
                    durationMillis += frame.duration
                }
            }
        )
    )

    val bitmap = remember { Bitmap().apply { allocPixels(codec.imageInfo) } }
    Canvas(modifier) {
        codec.readPixels(bitmap, frameIndex)
        drawImage(bitmap.asImageBitmap())
    }
}
Dominaezzz commented 3 years ago

Can this issue be closed now?

igordmn commented 3 years ago

We'll probably publish this as part of https://github.com/JetBrains/compose-jb/tree/master/components

Dominaezzz commented 3 years ago

Oooh, I'll make a PR then. (I might ping you on slack for help)

igordmn commented 3 years ago

Oooh, I'll make a PR then.

Awesome! Thanks :)

igordmn commented 3 years ago

https://github.com/JetBrains/compose-jb/pull/802 is closed, as there is no activity. We still plan to make it as a separate component in https://github.com/JetBrains/compose-jb/tree/master/components, when we find time to do it.

YeungKC commented 3 years ago

802 is closed, as there is no activity. We still plan to make it as a separate component in https://github.com/JetBrains/compose-jb/tree/master/components, when we find time to do it.

Why gif support is a separate component? Shouldn't the image itself support Gif.

igordmn commented 3 years ago

Why gif support is a separate component? Shouldn't the image itself support Gif.

Maybe, but it requires a lot more effort to make it Compose default. We need to make it to fit the core Compose architecture, discuss political questions, etc.

components is for the things which doesn't belong to the core Compose, or doesn't yet belong to the core Compose, without proper refactoring.

I don't have an answer yet, why Image itself shouldn't support Gif's, maybe during the development we decide to do it.

One of the things that stops me now - it is how animatedVectorResource was designed in Compose for Android. Here we retrieve each frame separately, Image itself don't do it. Maybe it is by-design that Image doesn't support animations, as it adds a functionality which doesn't belong here. The reason maybe the same, why Image won't support videos by default, or any other arbitrary source of frames.

bassstorm commented 2 years ago

Quite some time passed since Aug 2021... @igordmn may I ask for an update, please?

igordmn commented 2 years ago

There is no update.

Feel free to make a PR, adding a component to ”components” folder.

igordmn commented 2 years ago

@JetpackDuba implemented this component here. Thanks!

The component will be available in the next 1.2.0-build:

@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.animatedImage)
goldenduo commented 1 year ago

Compile Error: Unresolved reference: Unresolved reference: skija

BehnamMaboodi commented 9 months ago

What about ios?

phillwiggins commented 9 months ago

Was this originally intended to be just for desktop? It would be great to have support across Android & iOS as well.

okushnikov commented 4 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.