Видео последовательность в Drawable

    После поста о подходе Apple к кодированию видео в JPEG, решил рассказать о своем подобном «велосипеде» под Android.

    В своем мобильном проекте решили мы сделать превьюшки оружия не статической картинкой, а видео. Подразумевалось, что художники нарисуют красивые анимации, может даже в 3д, но что-то не сложилось и нам выдали простейшие зацикленные 1-1.5 секундные ролики в разрешении 256х256. В iOS версию они встроились замечательно, а вот в Android пришлось повоевать с MediaPlayer и SurfaceView, но все-равно получились некоторые «корявости» — содержимое SurfaceView не перемещалось вслед за родительским View, была заметная пауза при воспроизведении, и пр.

    Разумным решением было бы разбить анимации на кадры и оформить в xml для AnimationDrawable, но для 15 видов оружия это значило бы мусорку из 5000+ кадров по 10-15 кб каждый. Потому была сделана своя реализация AnimationDrawable, работающая с sprite sheet и относительно быстрый метод конверсии видео в такой формат.

    Sprite Sheet


    Карту спрайтов не мудрствуя лукаво решили делать горизонтальную, без файла описания. Это не идеально практично, но и для 1-2 секундных анимаций не критично.
    Исходное видео разбивается на спрайты через ffmpeg:

        ffmpeg -i gun.avi -f image2 gun-%02d.png

    Если получается больше 32 кадров, то добавляется параметер -r 25 или -r 20, чтобы уменьшить fps. Ограничение в 32 кадра взято из максимального разумного размера картинки по горизонтали в 8192 пикселя. Это можно обойти более сложным расположением спрайтов, но для последовтельности на 1-1.5 секунды этого достаточно.

    Получается вот такая россыпь файлов:



    Для сборки sprite sheet я использую Zwoptex, но подойдет любой похожий инструмент, или даже самописный скрипт.



    В примере с пистолетом получился png файл размером 257кб и разрешением 8192х256. После обрезки до 7424x256 и обработки через сайт TinyPNG он уменьшился до 101кб без потери качества. При желании можно еще и сохранить его в JPG с небольшой потерей качества и уменьшить до 50-70кб. Для сравнения, оригинальное видео в .MP4 с высоким качеством занимает те же 100кб. Для более сложных анимаций PNG может получиться в 2-3 раза больше оригинального видео, что на самом деле тоже не критично.



    Собственный AnimationDrawable


    В первоначальном варианте ставка была сделана на то, что Bitmap.createBitmap создает не новую картинку, а подмножество существующей в соответствии с описанием:

        Returns an immutable bitmap from the specified subset of the source bitmap.

    Конструктор загружает картинку, разбивает ее на кадры и добавляет их в AnimationDrawable. Анимации в нашем случае хранятся в assets для получения доступа по имени, но код очень просто адаптируется и для работы с R.drawable.*

    public class AssetAnimationDrawable extends AnimationDrawable {
    	public AssetAnimationDrawable(Context context, String asset, int frames,
    			int fps) throws IOException {
    
    		BitmapFactory.Options options = new BitmapFactory.Options();
    		options.inPreferredConfig = Config.RGB_565; // A.G.: use 16-bit mode without alpha for animations
    
    		this.bitmap = BitmapFactory.decodeStream(context.getResources()
    				.getAssets().open(asset), null, options);
    
    		int width = bitmap.getWidth() / frames;
    		int height = bitmap.getHeight();
    
    		int duration = 1000 / fps;		// A.G.: note the little gap cause of integer division.
    								// i.e. duration would be 33 for 30fps, meaning 990ms for 30 frames. 
    
    		for (int i = 0; i < frames; i++) {
    			Bitmap frame = Bitmap.createBitmap(bitmap, i * width, 0, width, height);
    			addFrame(new BitmapDrawable(frame), duration);
    		}
    	}
    }
    


    Используется класс, как обычный AnimationDrawable:

    	AnimationDrawable animation = new AssetAnimationDrawable(getContext(), "movies/gun.jpg", 28, 25);
    	animation.setOneShot(false);
    	previewImage.setImageDrawable(animation);
    	animation.start();
    


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

    Следующий вариант уже заметно сложнее и наследуется напрямую от Drawable. В конструкторе sprite sheet загружается в член класса, а в методе draw рисуется текущий кадр. Также класс реализует интерфейс Runnable по аналогии с оригинальным AnimationDrawable для анимации.

    	@Override
    	public void draw(Canvas canvas) {
    		canvas.drawBitmap(m_bitmap, m_frameRect, copyBounds(), m_bitmapPaint);
    	}
    
    
    	@Override
    	public void run() {
    		long tick = SystemClock.uptimeMillis();
    
    		if (tick - m_lastUpdate >= m_duration) {
    			m_frame = (int) (m_frame + (tick - m_lastUpdate) / m_duration)
    					% m_frames;
    			m_lastUpdate = tick; // TODO: time shift for incomplete frames
    
    			m_frameRect = new Rect(m_frame * m_width, 0, (m_frame + 1)
    					* m_width, m_height);
    			invalidateSelf();
    		}
    
    		scheduleSelf(this, tick + m_duration);
    	}
    
    	public void start() {
    		run();
    	}
    
    	public void stop() {
    		unscheduleSelf(this);
    	}
    
    	public void recycle() {
    		stop();
    		if (m_bitmap != null && !m_bitmap.isRecycled())
    			m_bitmap.recycle();
    	}
    


    В методе run() выполняется расчет текущего кадра и постановка задачи в очередь. Точность у приведенного кода будет не идеальная, потому что не учитывается дробное время кадра (например, когда tick — m_lastUpdate будет на 1мс меньше, чем duration), но в нашей задаче это было не актуально, а желающие могут доработать класс своими силами.
    Полный код на paste2: paste2.org/p/2240487

    Хочу обратить внимание на метод recycle(), который очищает m_bitmap. В большинстве случаев он не нужен, но у нас можно быстро прокликать покупки в магазине, из-за чего создается несколько AssetAnimationDrawable и может закончиться память, потому при создании новой анимации мы очищаем ресурсы старой.

    Плюсы и минусы


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

    Минусы:
    • наследуясь от Drawable мы теряем некоторые возможности AnimationDrawable, такие как setOneShot
    • изображение 8192x256x32bpp занимает 8Мб памяти
    • надо где-то хранить количество кадров и fps для каждой анимации
    • собственный код для стандартных решений ухудшает читабельность программы и усложняет ее поддержку
    • сжимая спрайты с jpg мы получим худшее качество, при том же размере, что и оригинальное видео. Сжимая в png мы получим такое же качество, при 1-3 раза большем размере


    Плюсы:
    • никаких багов с SurfaceView, MediaPlayer, торможений при загрузке
    • в режиме RGB_565 картинка 8192x256 занимает 4Мб памяти, а при нужде можно поставить в конструкторе options.inSampleSize = 2 или больше для уменьшения размеров и занимаемой памяти (при значении 2 получается 0.5Mб памяти и разрешение 4096x128)
    • можно отмасштабировать sprite sheet в любимом редакторе до любого размера. главное правило, чтобы ширина оставалась кратной количеству кадров
    • можно без особых проблем регулировать скорость воспроизведения через fps не изменяя готовые файлы
    • вполне реально воспроизводить анимацию с прозрачностью в режимах ARGB_8888 или ARGB_4444
    • в любой момент можно остановить анимацию и освободить ресурсы


    P.S.


    Если кому-то это будет интересно, могу отдельно рассказать об опыте интеграции небольших видео в GUI в MonoTouch для iOS проекта. Документации по Mono относительно мало, а подводных камней там достаточно.
    • +18
    • 4,5k
    • 9
    Поделиться публикацией

    Похожие публикации

    Комментарии 9

      0
      надо где-то хранить количество кадров и fps для каждой анимации

      а ширина / высота одного фрейма у всех спрайтов разная?
        0
        Если бы была разная, то пришлось бы хранить табличку с данными каждого спрайта, как делается в игровых движках.

        В моем случае это не особо нужно было, потому и прямоугольник каждого кадра очень просто определяется по формуле:
        Rect frameRect = new Rect(frame * width, 0, (frame + 1) * width, height);

        где height — высота большого битмапа (sprite sheet), width — его ширина, деленная на кол-во кадров, frame — номер кадра.
          0
          Я к тому, что если ширина и высота фрейма везде одинаковая, то зачем хранить кол-во фреймов?

          Недавно нужно было портировать приложение с JAVA-ME и не найдя в андроиде класса Sprite, пришлось его реализовать, подсматривая в сорцы JAVA-ME ( jcs.mobile-utopia.com/jcs/73812_Sprite.html ) и там как раз при инициализации спрайта задаётся ширина и высота фрейма, а не их кол-во. При чём спрайт не важно горизонтально, вертикально или в 5 колонок нарисован — результат один.
            0
            Зная количество кадров и геометрию расположения, мы можем как-угодно масштабировать большую картинку — код не изменится. Например, под разные разрешения можно спокойно держать битмапы в папках drawable-hdpi, drawable-xhdpi и пр.
            В «правильной» же реализации для каждого фрейма хранится отдельное описание, включая координаты, размер и параметры кропа. Тот же Zwoptex может генерить такие файлы в любом формате, включая и cocos2d.
        0
        m_frameRect — лучше не создавать каждый раз, а использовать старый.
          0
          >>Разумным решением было бы разбить анимации на кадры и оформить в xml для AnimationDrawable, но для 15 видов оружия это значило бы мусорку из 5000+ кадров по 10-15 кб каждый.

          Что-то я очень сомневаюсь, с большой вероятностью вы бы начали вылетать с out of memory, хотя все зависит от размера одного кадра.
            0
            Что-то я очень сомневаюсь, с большой вероятностью вы бы начали вылетать с out of memory, хотя все зависит от размера одного кадра.
            Собственно, memory footprint для такого решения 1в1 такой же, как и для Sprite Sheet, описанного в статье (+- мелкие расходы на разные Bitmap). Может, прочитаете?
              0
              Я прочитал, и спасибо за статью, вариант интересный.
              Вроде не зря подметил, что все зависит от размера одного кадра. Если взять допустим 20-30 кадров, с размером кадра где-то 200х200, точно сейчас не скажу, то со стандартным animationdrawable упадем с out of memory, т.к. все грузится сразу в память, довольно известная проблема…
                0
                32 кадра с размером 256х256 дадут нам 32 штуки Bitmap -> BitmapDrawable, а это будет как раз 8 мб памяти в режиме ARGB_8888 (если кадры с расширением PNG, то наверняка именно так они и загрузятся). Собственно, промежуточный вариант с наследником AnimationDrawable так и делал, только принудительно ставил режим RGB_565, а это уже в 2 раза оптимальнее.
                По-сути, sprite sheet не дает нам существенного выигрыша в памяти, только в удобстве работы. Более того, когда я в своей Косынке под Андроид решил не грузить 52 карты из разных файлов, а соединил в большой sprite sheet, получилась загрузка примерно в 2 раза медленее — около секунды вместо старых 0.3-0.5.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.