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

в 11:36, , рубрики: android, animation, drawable, java, анимация, велосипед, видео, Работа с видео, Разработка под android, метки: , , , , , ,

После поста о подходе 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 секунды этого достаточно.

Получается вот такая россыпь файлов:
Видео последовательность в Drawable

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

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

Собственный 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 относительно мало, а подводных камней там достаточно.

Автор: Nomad1

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js