Используя Glide, как я могу просмотреть каждый кадр GifDrawable, как Bitmap?

Фон

В живых обоях у меня есть экземпляр Canvas, в который я хочу рисовать контент GIF/WEBP, который был загружен через Glide.

Причина, по которой я хочу сделать это с Glide, заключается в том, что он обеспечивает некоторые преимущества по сравнению с решением, которое я нашел в прошлом для того же ( здесь, репозиторий здесь):

  1. Использование Movie ограничивает меня только GIF. С Glide я также мог поддерживать анимацию WEBP
  2. Использование Movie кажется неэффективным, поскольку оно не говорит мне время ожидания между кадрами, поэтому я должен выбрать FPS, который я хочу попробовать использовать. Это также устарело на Android P.
  3. Glide может облегчить обработку различного масштабирования.
  4. Glide может не зависать, как в исходном коде, и может обеспечить лучший контроль над механизмом.

Эта проблема

Glide, похоже, оптимизирован для работы только с обычным пользовательским интерфейсом (Views). У него есть некоторые основные функции, но наиболее важные из того, что я пытаюсь сделать, кажутся частными.

Что я нашел

Я использую официальную библиотеку Glide (v 3.8.0) для загрузки GIF и GlideWebpDecoder для загрузки WEBP (с той же версией).

Основной вызов для загрузки каждого из них, как таковой:

GIF:

    GlideApp.with(this).asGif()
            .load("https://res.cloudinary.com/demo/image/upload/bored_animation.gif")
            .into(object : SimpleTarget<GifDrawable>() {
                override fun onResourceReady(resource: GifDrawable, transition: Transition<in GifDrawable>?) {
                    //example of usage:
                    imageView.setImageDrawable(resource)
                    resource.start()
                }
            })

WebP:

        GlideApp.with(this).asDrawable()
                .load("https://res.cloudinary.com/demo/image/upload/fl_awebp/bored_animation.webp")
//                .optionalTransform(WebpDrawable::class.java, WebpDrawableTransformation(CircleCrop()))
                .into(object : SimpleTarget<Drawable>() {
                    override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
                        //example of usage:
                        imageView.setImageDrawable(resource)
                        if (resource is Animatable) {
                            (resource as Animatable).start()
                        }
                    }
                })

Теперь, помните, у меня действительно нет ImageView, и вместо этого у меня есть только Canvas, через который я получаю surfaceHolder.lockCanvas() вызов.

                    resource.callback = object : Drawable.Callback {
                        override fun invalidateDrawable(who: Drawable) {
                            Log.d("AppLog", "frame ${resource.frameIndex}/${resource.frameCount}")
                        }

                    }

Однако, когда я пытаюсь получить растровое изображение для текущего кадра, мне не удается найти правильную функцию.

Я попробовал это, например, (и это только пример, чтобы увидеть, может ли он работать с холстом):

    val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)

    ...
    resource.draw(canvas)

Но, похоже, он не рисует контент в растровом изображении, и я думаю, что это потому, что его draw Функция имеет следующие строки кода:

  @Override
  public void draw(@NonNull Canvas canvas) {
    if (isRecycled) {
      return;
    }

    if (applyGravity) {
      Gravity.apply(GRAVITY, getIntrinsicWidth(), getIntrinsicHeight(), getBounds(), getDestRect());
      applyGravity = false;
    }

    Bitmap currentFrame = state.frameLoader.getCurrentFrame();
    canvas.drawBitmap(currentFrame, null, getDestRect(), getPaint());
  }

Все же getDestRect() возвращает прямоугольник размером 0, который я не могу найти, как изменить: он также является закрытым, и я не вижу ничего, что изменяет его.

Вопросы

  1. Предположим, я получил Drawable, которое я хочу использовать (GIF/WEBP), как я могу получить каждый из кадров, которые он может создать (а не только первый кадр), и нарисовать его на холсте (с правильным количеством времени между кадрами), конечно)?

  2. Можно ли как-то установить тип масштабирования, как в ImageView (обрезка по центру, подгонка по центру, по центру...)?

  3. Возможно, есть лучшая альтернатива этому? Может быть, предположим, что у меня есть файл анимации GIF/WEBP, позволяет ли Glide использовать его декодер? Что-то вроде этой библиотеки?


РЕДАКТИРОВАТЬ:

Я нашел хорошую альтернативную библиотеку, которая позволяет загружать GIF один кадр за другим, здесь. Это не кажется эффективным при загрузке покадрово, но это с открытым исходным кодом и может быть легко изменено для лучшей работы.

Тем не менее, может быть гораздо приятнее сделать это на Glide, так как он поддерживает масштабирование и загрузку WEBP.

Я сделал POC (ссылка здесь), которая показывает, что он действительно может идти кадр за кадром, ожидая подходящего времени между ними. Если кому-то удастся сделать то же самое, что и я, но на Glide (конечно, последняя версия Glide), я приму ответ и предоставлю вознаграждение. Вот код:

** GifPlayer.kt, на основе NsGifPlayer.java **

open class GifPlayer {
    companion object {
        const val ENABLE_CACHING = false
        const val MEM_CACHE_SIZE_PERCENT = 0.8
        fun calculateMemCacheSize(percent: Double): Long {
            if (percent < 0.05f || percent > 0.8f) {
                throw IllegalArgumentException("setMemCacheSizePercent - percent must be " + "between 0.05 and 0.8 (inclusive)")
            }
            val maxMem = Runtime.getRuntime().maxMemory()
//            Log.d("AppLog", "max mem :$maxMem")
            return Math.round(percent * maxMem)
        }
    }

    private val uiHandler = Handler(Looper.getMainLooper())
    private var playerHandlerThread: HandlerThread? = null
    private var playerHandler: Handler? = null
    private val gifDecoder: GifDecoder = GifDecoder()
    private var currentFrame: Int = -1
    var listener: GifListener? = null
    var state: State = State.IDLE
        private set
    private val playRunnable: Runnable
    private val frames = HashMap<Int, AnimationFrame>()
    private var currentUsedMemByCache = 0L

    class AnimationFrame(val bitmap: Bitmap, val duration: Long)

    enum class State {
        IDLE, PAUSED, PLAYING, RECYCLED, ERROR
    }

    interface GifListener {
        fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)

        fun onError()
    }

    init {
        val memCacheSize = if (ENABLE_CACHING) calculateMemCacheSize(MEM_CACHE_SIZE_PERCENT) else 0L
//        Log.d("AppLog", "memCacheSize:$memCacheSize = ${memCacheSize / 1024L} MB")
        playRunnable = object : Runnable {
            override fun run() {
                val frameCount = gifDecoder.frameCount
                gifDecoder.setCurIndex(currentFrame)
                currentFrame = (currentFrame + 1) % frameCount
                val animationFrame = if (ENABLE_CACHING) frames[currentFrame] else null
                if (animationFrame != null) {
//                    Log.d("AppLog", "cache hit - $currentFrame")
                    val bitmap = animationFrame.bitmap
                    val delay = animationFrame.duration
                    uiHandler.post {
                        listener?.onGotFrame(bitmap, currentFrame, frameCount)
                        if (state == State.PLAYING)
                            playerHandler!!.postDelayed(this, delay)
                    }
                } else {
//                    Log.d("AppLog", "cache miss - $currentFrame fill:${frames.size}/$frameCount")
                    val bitmap = gifDecoder.bitmap
                    val delay = gifDecoder.decodeNextFrame().toLong()
                    if (ENABLE_CACHING) {
                        val bitmapSize = BitmapCompat.getAllocationByteCount(bitmap)
                        if (bitmapSize + currentUsedMemByCache < memCacheSize) {
                            val cacheBitmap = Bitmap.createBitmap(bitmap)
                            frames[currentFrame] = AnimationFrame(cacheBitmap, delay)
                            currentUsedMemByCache += bitmapSize
                        }
                    }
                    uiHandler.post {
                        listener?.onGotFrame(bitmap, currentFrame, frameCount)
                        if (state == State.PLAYING)
                            playerHandler!!.postDelayed(this, delay)
                    }
                }
            }
        }
    }

    @Suppress("unused")
    protected fun finalize() {
        stop()
    }

    @UiThread
    fun start(filePath: String): Boolean {
        if (state != State.IDLE && state != State.ERROR)
            return false
        currentFrame = -1
        state = State.PLAYING
        playerHandlerThread = HandlerThread("GifPlayer")
        playerHandlerThread!!.start()
        val looper = playerHandlerThread!!.looper
        playerHandler = Handler(looper)
        playerHandler!!.post {
            try {
                gifDecoder.load(filePath)
            } catch (e: Exception) {
                uiHandler.post {
                    state = State.ERROR
                    listener?.onError()
                }
                return@post
            }

            val bitmap = gifDecoder.bitmap
            if (bitmap != null) {
                playRunnable.run()
            } else {
                frames.clear()
                gifDecoder.recycle()
                uiHandler.post {
                    state = State.ERROR
                    listener?.onError()
                }
                return@post
            }
        }
        return true
    }

    @UiThread
    fun stop(): Boolean {
        if (state == State.IDLE)
            return false
        state = State.IDLE
        playerHandler!!.removeCallbacks(playRunnable)
        playerHandlerThread!!.quit()
        playerHandlerThread = null
        playerHandler = null
        return true
    }

    @UiThread
    fun pause(): Boolean {
        if (state != State.PLAYING)
            return false
        state = State.PAUSED
        playerHandler?.removeCallbacks(playRunnable)
        return true
    }

    @UiThread
    fun resume(): Boolean {
        if (state != State.PAUSED)
            return false
        state = State.PLAYING
        playerHandler?.removeCallbacks(playRunnable)
        playRunnable.run()
        return true
    }

    @UiThread
    fun toggle(): Boolean {
        when (state) {
            State.PLAYING -> pause()
            State.PAUSED -> resume()
            else -> return false
        }
        return true
    }

}

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var player: GifPlayer

    @SuppressLint("StaticFieldLeak")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val file = File(this@MainActivity.filesDir, "file.gif")
        object : AsyncTask<Void, Void, Void?>() {

            override fun doInBackground(vararg params: Void?): Void? {
                val inputStream = resources.openRawResource(R.raw.fast)
                if (!file.exists()) {
                    file.parentFile.mkdirs()
                    val outputStream = FileOutputStream(file)
                    val buf = ByteArray(1024)
                    var len: Int
                    while (true) {
                        len = inputStream.read(buf)
                        if (len <= 0)
                            break
                        outputStream.write(buf, 0, len)
                    }
                    inputStream.close()
                    outputStream.close()
                }
                return null
            }

            override fun onPostExecute(result: Void?) {
                super.onPostExecute(result)
                player.setFilePath(file.absolutePath)
                player.start()
            }

        }.execute()

        player = GifPlayer(object : GifPlayer.GifListener {
            override fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int) {
                Log.d("AppLog", "onGotFrame $frame/$frameCount")
                imageView.post {
                    imageView.setImageBitmap(bitmap)
                }
            }

            override fun onError() {
                Log.d("AppLog", "onError")
            }
        })
    }

    override fun onStart() {
        super.onStart()
        player.resume()
    }

    override fun onStop() {
        super.onStop()
        player.pause()
    }

    override fun onDestroy() {
        super.onDestroy()
        player.stop()
    }
}

1 ответ

У меня было похожее требование, когда я хотел отобразить предварительный просмотр вместо анимации при загрузке GIF в Glide.

Мое решение состояло в том, чтобы взять первый кадр из GifDrawable и представить его как весь нарисованный. Тот же подход может быть адаптирован для отображения других кадров (или для экспорта и т. Д.)

DrawableRequestBuilder builder = Glide.with(ctx).load(someUrl);
builder.listener(new RequestListener<String, GlideDrawable>() {
    @Override
    public boolean onException(Exception e, String model, Target<GlideDrawable> target, boolean isFirstResource) {
        return false;
    }

    @Override
    public boolean onResourceReady(GlideDrawable resource, String model, Target<GlideDrawable> target, boolean isFromMemoryCache, boolean isFirstResource) {
        if (resource.isAnimated()) {
            target.onResourceReady(new GlideBitmapDrawable(null, ((GifDrawable) resource).getFirstFrame()), null);
        }
        return handled;
    }
});
builder.into(mImageView);

Вы можете либо выполнить анимацию, чтобы получить ключевые кадры, либо получить их по индексу в обратном вызове, непосредственно обращаясь к decoder прикреплен к GifDrawable. В качестве альтернативы установите Callback (фактическое имя класса) на Drawable, когда он будет готов. Это будет называться onFrameReady (Давать вам текущий кадр в Drawable каждый раз). Класс drawable gif уже управляет пулом растровых изображений.

Когда GifDrawable будет готов, переберите кадры с помощью следующего метода:

GifDrawable gd = (GifDrawable) resource;
Bitmap b = gd.getDecoder().getNextFrame();  

Обратите внимание, что если вы используете декодер, вы должны сделать это из onResourceReady Обратный звонок я упоминал выше. У меня были перемежающиеся проблемы, когда я пытался сделать это раньше.

Если вы позволите декодеру работать автоматически, вы можете получить обратные вызовы для кадров

gifDrawable.setCallback(new Drawable.Callback() {
    @Override
    public void invalidateDrawable(@NonNull Drawable who) {
        //NOTE: this method is called each time the GifDrawable updates itself with a new frame
        //who.draw(canvas); //if you already have a canvas
        //https://stackru.com/questions/3035692/how-to-convert-a-drawable-to-a-bitmap //if you really want a bitmap
    }

    @Override public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { /* ignore */ }
    @Override public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { /* ignore */ }
});

В то время это был лучший доступный подход. Так как прошло более года, я не могу гарантировать, что сейчас нет более эффективного способа сделать это.

Я использую версию библиотеки Glide 3.7.0. Доступ ограничен в последней версии 4.7.+, Но я не уверен, как далеко вы должны зайти, чтобы использовать мой подход.

В любом случае, мы будем использовать недокументированные методы от Glide, и я надеюсь, что в один прекрасный день команда Glide опубликует их. Вам понадобится немного опыта работы с Java Reflection:) Вот стенд кода для извлечения Bitmap из файла GIF:

ArrayList bitmaps = new ArrayList<>();
Glide.with(AppObj.getContext())
            .asGif()
            .load(GIF_PATH)
            .into(new SimpleTarget<GifDrawable>() {
                @Override
                public void onResourceReady(@NonNull GifDrawable resource, @Nullable Transition<? super GifDrawable> transition) {
                    try {
                        Object GifState = resource.getConstantState();
                        Field frameLoader = GifState.getClass().getDeclaredField("frameLoader");
                        frameLoader.setAccessible(true);
                        Object gifFrameLoader = frameLoader.get(GifState);

                        Field gifDecoder = gifFrameLoader.getClass().getDeclaredField("gifDecoder");
                        gifDecoder.setAccessible(true);
                        StandardGifDecoder standardGifDecoder = (StandardGifDecoder) gifDecoder.get(gifFrameLoader);
                        for (int i = 0; i < standardGifDecoder.getFrameCount(); i++) {
                            standardGifDecoder.advance();
                            bitmaps.add(standardGifDecoder.getNextFrame());
                        }
                    } catch (Exception ex) {
                        ex.printStackTrace();
                    }

                }
            });

}

Хорошо, я нашел 3 возможных решения:

  1. Если вы хотите, чтобы кадры появлялись при воспроизведении Drawable, вы можете сделать следующее:
private fun testGif() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_gif).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE).submit().get() as GifDrawable
    val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    drawable.setBounds(0, 0, bitmap.width, bitmap.height)
    drawable.setLoopCount(1)
    val callback = object : CallbackEx() {
        override fun invalidateDrawable(who: Drawable) {
            super.invalidateDrawable(who)
            val gif = who as GifDrawable
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
            who.draw(canvas)
            //image is available here on the bitmap object
            Log.d("AppLog", "frameIndex:${gif.frameIndex} frameCount:${gif.frameCount} firstFrame:${gif.firstFrame}")
        }
    }
    drawable.callback = callback
    drawable.start()
}

private fun testWebp() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_webp).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE)
            .submit().get() as WebpDrawable
    val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    drawable.setBounds(0, 0, bitmap.width, bitmap.height)
    drawable.loopCount = 1
    val callback = object : CallbackEx() {
        override fun invalidateDrawable(who: Drawable) {
            val webp = who as WebpDrawable
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
            who.draw(canvas)
            //image is available here on the bitmap object
            Log.d("AppLog", "frameIndex:${webp.frameIndex} frameCount:${webp.frameCount} firstFrame:${webp.firstFrame}")
        }
    }
    drawable.callback = callback
    drawable.start()
}
  1. Если вас устраивает размышление о том, что вы получаете от Glide, вы можете использовать следующее:
private fun testWebp2() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_webp).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE)
            .submit().get() as WebpDrawable
    drawable.constantState
    val state = drawable.constantState as Drawable.ConstantState
    val frameLoader: Field = state::class.java.getDeclaredField("frameLoader")
    frameLoader.isAccessible = true
    @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
    val webpFrameLoader = frameLoader.get(state) as WebpFrameLoader
    val webpDecoder: Field = webpFrameLoader.javaClass.getDeclaredField("webpDecoder")
    webpDecoder.isAccessible = true
    val standardGifDecoder = webpDecoder.get(webpFrameLoader) as GifDecoder
    Log.d("AppLog", "got ${standardGifDecoder.frameCount} frames:")
    for (i in 0 until standardGifDecoder.frameCount) {
        val delay = standardGifDecoder.nextDelay
        val bitmap = standardGifDecoder.nextFrame
        //image is available here on the bitmap object
        Log.d("AppLog", "${standardGifDecoder.currentFrameIndex} - $delay ${bitmap?.width}x${bitmap?.height}")
        standardGifDecoder.advance()
    }
    Log.d("AppLog", "done")
}

private fun testGif2() {
    val drawable = GlideApp.with(applicationContext).load(R.raw.test_gif).skipMemoryCache(true)
            .diskCacheStrategy(DiskCacheStrategy.NONE).submit().get() as GifDrawable
    val state = drawable.constantState as Drawable.ConstantState
    val frameLoader: Field = state::class.java.getDeclaredField("frameLoader")
    frameLoader.isAccessible = true
    val gifFrameLoader: Any = frameLoader.get(state)
    val gifDecoder: Field = gifFrameLoader.javaClass.getDeclaredField("gifDecoder")
    gifDecoder.isAccessible = true
    val standardGifDecoder = gifDecoder.get(gifFrameLoader) as StandardGifDecoder
    Log.d("AppLog", "got ${standardGifDecoder.frameCount} frames:")
    val parent = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "gifFrames")
    parent.mkdirs()
    for (i in 0 until standardGifDecoder.frameCount) {
        val file = File(parent, "${String.format("%07d", i)}.png")
        val delay = standardGifDecoder.nextDelay
        val bitmap = standardGifDecoder.nextFrame
        if (bitmap == null) {
            Log.d("AppLog", "error getting frame")
            break
        }
        //image is available here on the bitmap object
        Log.d("AppLog", "${standardGifDecoder.currentFrameIndex} - $delay ${bitmap?.width}x${bitmap?.height}")
        standardGifDecoder.advance()
    }
    Log.d("AppLog", "done")
}
  1. И, наконец, если вам нужно немного более низкоуровневое решение, вы можете сделать вот так:
    private fun testGif3() {
        // found from GifDrawableResource StreamGifDecoder StandardGifDecoder
        val data = resources.openRawResource(R.raw.test_gif).readBytes()
        val byteBuffer = ByteBuffer.wrap(data)
        val glide = GlideApp.get(this)
        val gifBitmapProvider = GifBitmapProvider(glide.bitmapPool,  glide.arrayPool)
        val header = GifHeaderParser().setData(byteBuffer).parseHeader()
        val standardGifDecoder = StandardGifDecoder(gifBitmapProvider, header, byteBuffer, 1)
        //alternative, without getting header and needing sample size:
//        val standardGifDecoder = StandardGifDecoder(gifBitmapProvider)
//        standardGifDecoder.read(data)
        val frameCount = standardGifDecoder.frameCount
        standardGifDecoder.advance()
        for (i in 0 until frameCount) {
            val delay = standardGifDecoder.nextDelay
            val bitmap = standardGifDecoder.nextFrame
            //bitmap ready here
            standardGifDecoder.advance()
        }
    }

    private fun testWebP3() {
        //found from  ByteBufferWebpDecoder  StreamWebpDecoder  WebpDecoder
        val data = resources.openRawResource(R.raw.test_webp).readBytes()
        val cacheStrategy: WebpFrameCacheStrategy? = Options().get(WebpFrameLoader.FRAME_CACHE_STRATEGY)
        val glide = GlideApp.get(this)
        val bitmapPool = glide.bitmapPool
        val arrayPool = glide.arrayPool
        val gifBitmapProvider = GifBitmapProvider(bitmapPool, arrayPool)
        val webpImage = WebpImage.create(data)
        val sampleSize = 1
        val webpDecoder = WebpDecoder(gifBitmapProvider, webpImage, ByteBuffer.wrap(data), sampleSize, cacheStrategy)
        val frameCount = webpDecoder.frameCount
        webpDecoder.advance()
        for (i in 0 until frameCount) {
            val delay = webpDecoder.nextDelay
            val bitmap = webpDecoder.nextFrame
            //bitmap ready here
            webpDecoder.advance()
        }
    }
Другие вопросы по тегам