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