Уверен, что многих возмутит уже самоназвание этой статьи. А некоторые сразу же побегут в комментарии указывать на приложение, которое «смогло». Но не стоит спешить, друзья! Сегодня вам предстоит увлекательное путешествие по стыку технологий, кода и технических решений, которые и расскажут вам то, о чем адепты съемки мобильного RAW-видео предпочитают не говорить.

Я разберу лишь основные моменты, которые и убедили меня в том, что эффективная съемка RAW‑видео на Андроид на сегодняшний день невозможна без »костылей» и ухищрений. Костылей, которые нивелируют все те преимущества RAW, которые так жаждут получить на своих смартфонах видеографы. Ухищрений, которые по итогу делают менее ресурсоемкие форматы записи видео на смартфоне даже более эффективными и качественными, чем RAW.
Да, будет интересно!
А еще для демонстрации и подтверждения общих положений я опубликую рабочий код на языке Java. Чтобы повторить описанное или даже что-то улучшить, вам нужно знать Java и уметь читать документацию Android.
Но сначала представлюсь
Меня зовут Александр Трофимов, я программист и энтузиаст мобильной видеографии. А еще я разработчик уже довольно известного приложения профессиональной видеосъемки для Андроид-смартфонов mcpro24fps.
Что побудило меня написать эту статью?
Прежде всего, желание показать реальное положение вещей. Ведь сами понятия RAW-видео, как и Log-видеоперекочевали на смартфоны из мира «взрослых» и больших камер. А маркетологи приложили все усилия к тому, чтобы пользователь считал, что его смартфон за 300 долларов уже давно снимает лучше профессиональной камеры за 300 тысяч долларов.
В процессе работы над своим приложением я провожу достаточно много времени в изысканиях и экспериментах со смартфонами самого разного класса. А еще я получаю фидбек от тысяч пользователей с их чаяниями и просьбами.
С момента написания предыдущих статей (тык), (тык) и (тык) мне удалось существенно прокачать Log-съемку и все, что с ней связано. В том числе, дать возможность адаптировать Log гамма‑кривые от «взрослых» камер с большими сенсорами под маленькие сенсоры каждого конкретного смартфона.
И вот, казалось бы, эффективность Log на смартфоне доведена до предела, но..
ВСЕ ХОТЯТ ещё и RAW!

Почему? А потому что:
«У меня вообще‑то флагман, я деньги заплатил!»
«А у меня восемнадцать ядер в телефоне, внешний аккумулятор и карта памяти внешняя — должен всё тянуть!»
Ну и так далее..
Говоря о самых мощных процессорах, стопитсотмегапиксельных сенсорах многие как‑то забывают об ограничениях наших с вами компактных Андроид‑смартфонов. Ограничениях, которые мы и разберем подробно далее..
Что же такое RAW?
И начнем мы с самого простого — определения того, как выглядит RAW‑видео на взрослых камерах, какие стандарты существуют. И здесь негде разбежаться. Их всего два:
Старый добрый ZIP с бесконечным количеством кадров в формате DNG.
MXF контейнер, с метаданными CinemaDNG и «колбасой» данных в формате DNG. Преимущество этого формата заключается в том, что здесь можно указать много всяких метаданных, включая скорость кадров, которые понимают монтажные программы.
Всё. Остальные подходы — это костыли и уловки, к которым индустрия видео не приучена. А значит, монтажные приложения не будут это поддерживать.
Погружаемся в код
Сейчас будет много кода и пояснений к нему. Те, кто не очень умеет в код, могут сразу переходить к разделу с практическими экспериментами!
Очень хотелось бы воспользоваться контейнером MXF, но, похоже, для этого придется писать свой Muxer, поддерживающий этот контейнер. Для этого надо прочитать документацию, понять ее, и правильно реализовать. На это надо достаточно много времени, а ниже мы поймем, что и это нас бы не спасло (хотя надежда остается до момента ее «убийства»). Отбрасываем этот вариант и возвращаемся к классике, которой пользуются разные видео‑камеры среднего ценового сегмента.
И так, наша задача выглядит проще некуда. Разложим ее по шагам.
Взять сырые данные с сенсора камеры.
Сформировать на их основе файл DNG.
Положить DNG в ZIP.
Шаг первый.
Запускаем сессию захвата, где хотя бы одна поверхность (Surface) настроена на RAW_SENSOR. RAW_SENSOR есть почти у всех, и этот 16-битный формат сразу готов для работы с нативным DNGCreator. Можно не заморачиваться о том, сколько бит выдает сенсор. Для того, чтобы сессия захвата могла сконфигурировать нужную нам поверхность, мы будет использовать ImageReader.
rawImageReader = ImageReader.newInstance(rawResolution.getWidth(), rawResolution.getHeight(), ImageFormat.RAW_SENSOR, 2);
rawResolution это поддерживаемое разрешение, взятое из системной информации.
Для размера буфера взято всего 2 кадра, потому что нас не интересует отсрочка проблемы производительности. Е��ли есть проблема, мы хотим ее увидеть сразу.
ImageReader готов, теперь надо добавить OnImageAvailableListener, чтобы получать кадры и иметь возможность обработать их. Сначала самый простой вариант:
ImageReader.OnImageAvailableListener listener = r -> { Image i = null; if (!RECORDING_STARTED) { try { i = r.acquireLatestImage(); } finally { if (i != null) try { i.close(); } finally { i = null; } } return; } try { i = r.acquireNextImage(); } catch (IllegalStateException e) { e.printStackTrace(); i = null; } finally { if (i != null) try { i.close(); } finally { i = null; } } }; mRAWImageReader.setOnImageAvailableListener(listener, handler);
Пока запись не началась, мы, чтоб не тратить ресурсы, из всего буфера выбираем только самый последний кадр. Как только начинается запись, мы начинаем считывать каждый кадр. Теперь i это наш кадр, нам надо его обработать и сохранить.
Шаг второй.
DngCreator dngCreator = new DngCreator(cameraCharacteristics, captureResult); dngCreator.writeImage(outputStream, i);
Создаем DNGCreator на основе характеристик камеры и результата захвата. Но… откуда у нас результат захвата? А ни от куда. У нас его нет, мы должны его получить, где-то временно сохранить, и выдать его при создании файла DNG.
Как это сделать? Очевидно, нужен кеш. Для этого мы будем использовать LruCache, где Long это таймкод кадра, а CaptureResult результат захвата в onCaptureCompleted в функции обратного вызова сессии захвата. LruCache это кеш, который имеет ограниченное количество элементов, что защищает нас от утечки памяти.
if (RECORDING_STARTED) { rawTime = result.get(CaptureResult.SENSOR_TIMESTAMP); if (rawTime != null) { сaptureResultsCache.put(rawTime, result); } }
Сохраняем в кеш только, если начата запись.
Теперь достаточно обратиться к кешу и получить н��жную нам запись.
long timestamp = i.getTimestamp(); captureResult = captureResultsCache.get(timestamp);
Казалось бы, вот оно, осталось только сохранить/упаковать в ZIP готовый файл. Но нет. У нас две проблемы:
CaptureResult в onCaptureCompleted приходит позже, чем приходит кадр в OnImageAvailableListener.
DNG может создаваться так долго, что мешает сессии захвата, опуская скорость кадров почти до нуля и в конце концов вешая приложение.
Чтобы решить первую проблему, нам, очевидно, нужен кеш для кадров, из которого мы будем пытаться получить кадр при получении CaptureResult в onCaptureCompleted. Для кеша мы можем использовать LruCache, но этим мы только усугубим проблему 2, потому что Image, а с ним и буфер кадра, будет заблокирован до момент, пока не будет прочитан из кеша и закрыт. Поэтому мы пойдем путем решения обеих проблем одновременно. Перво-наперво нам надо как можно быстрее освободить Image, закрыть его. Также всю обработку DNG надо вынести в отдельную ветку. Для этого мы будем получать ByteBuffer из Image, конвертировать его в byte[], и сохранять в кеш, тут же освобождая Image через close(); Для того, чтобы в кеш можно было сохранить дополнительные данные размера кадра и CaptureResult (так будет удобней), мы создаем свой класс объекта DngPacket.
public class DngPacket { final byte[] dngData; final Size size; final long timestamp; CaptureResult result = null; DngPacket(byte[] dngData, Size size, long timestamp) { this.dngData = dngData; this.timestamp = timestamp; this.size = size; } DngPacket(byte[] dngData, Size size, long timestamp, CaptureResult result) { this.dngData = dngData; this.timestamp = timestamp; this.size = size; this.result = result; } }
Для отдельных веток мы используем ExecutorService mExecutor и execute();
После того, как мы добавим обработку DNG и кеш, мы обнаружим, что стало очень неудобно вызывать обработку DNG и последующее сохранение его в ZIP. Поэтому мы добавляем очередь с фиксированным количеством элементов, в которой будет происходить создание DNG — LinkedBlockingQueue dngQueue;
На старте записи мы определяем количество элементов.
dngQueue = new LinkedBlockingQueue<>(4);
И создаем ветку, в которой эта очередь будет читаться и обрабатываться.
dngWriterThread = new Thread(() -> { try { while (RECORDING_STARTED || dngQueue.isEmpty()) { DngPacket packetOriginal = null; try { packetOriginal = dngQueue.poll(300, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } if (packetOriginal != null) { final DngPacket packet = packetOriginal; if (mExecutor != null && !mExecutor.isShutdown()) { mExecutor.execute(() -> { try { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); DngCreator dngCreator = new DngCreator(mCameraCharacteristics, packet.result); dngCreator.writeByteBuffer(byteArrayOutputStream, packet.size, ByteBuffer.wrap(packet.dngData), 0); try { dngCreator.close(); } finally { // } byte[] dngBytes = byteArrayOutputStream.toByteArray(); try { zipQueue.offer(new DngZipPacket(dngBytes, packet.timestamp + ".dng")); // да, здесь снова очередь, но // теперь для сохранения в ZIP. } catch (Exception e) { } } catch (IOException e) { } }); } } if (!RECORDING_STARTED && dngQueue.isEmpty()) { dngQueue.clear(); break; } } } finally { // } }, "DNGWriterThread"); dngWriterThread.start();
Здесь мы снова используем mExecutor, чтобы создание DNG происходило в отдельной ветке и не блокировало ветку получения данных из очереди.
Еще наблюдательные могут заметить новый объект DngZipPacket. Это тоже отдельный класс для более простой передачи в очередь и последующего чтения.
public class DngZipPacket { final byte[] dngData; final String entryName; DngZipPacket(byte[] dngData, String entryName) { this.dngData = dngData; this.entryName = entryName; } }
В результате получаем такой setOnImageAvailableListener.
ImageReader.OnImageAvailableListener listener = r -> { Image i = null; if (!RECORDING_STARTED) { try { i = r.acquireLatestImage(); } finally { if (i != null) try { i.close(); } finally { i = null; } } return; } if (zipOutputStream == null) { stopRAWRecording(); // функция для остановки записи return; } try { i = r.acquireNextImage(); } catch (IllegalStateException e) { e.printStackTrace(); i = null; return; } if (i == null || i.getFormat() != ImageFormat.RAW_SENSOR) { return; } final Image rawImage = i; if (mExecutor != null && !mExecutor.isShutdown()) { mExecutor.execute(() -> { try { if (сameraCharacteristics == null) { rawImage.close(); return; } long timestamp = rawImage.getTimestamp(); Size size = new Size(rawImage.getWidth(), rawImage.getHeight()); byte[] bytes = new byte[rawImage.getPlanes()[0].getBuffer().remaining()]; rawImage.getPlanes()[0].getBuffer().get(bytes); try { rawImage.close(); } finally { // } CaptureResult captureResult = captureResultsCache.get(timestamp); if (captureResult == null) { // если не находим CaptureResult, сохраняем кадр в кеш mDNGCache.put(timestamp, new DngPacket(bytes, size, timestamp)); return; } dngQueue.offer(new DngPacket(bytes, size, timestamp, captureResult)) } catch (Exception e) { e.printStackTrace(); } }); } };
Отдельное внимание хочу обратить на dngQueue.offer. Мы используем offer вместо put, потому что put ждет, пока освободится место в очереди, чем блокирует функцию. offer пытается вставить элемент в очередь, но если места нет, просто откидывает его. Нам нет смысла пытаться впихнуть все. Если производительности не хватает, то так тому и быть.
Кеш для DNG без CaptureResult выглядит так.
LruCache<Long, DngPacket> mDNGCache; mDNGCache = new LruCache<>(4);
Все RAW буферы, у которых нашлись данные CaptureResult в кеше captureResultsCache отправляются в очередь на создание DNG и последующее сохранение в ZIP.
Теперь мы вспоминаем, для чего вообще нам был нужен кеш для DNG. Для того, чтобы реагировать в ситуации, когда CaptureResult приходит позже кадра. Для этого мы редактируем код в onCaptureCompleted.
if (RECORDING_STARTED) { rawTime = result.get(CaptureResult.SENSOR_TIMESTAMP); if (rawTime != null) { DngPacket packet = mDNGCache.get(rawTime); if (packet != null) { packet.result = result; try { dngQueue.offer(packet); } catch (Exception e) { // } mDNGCache.remove(rawTime); } else { captureResultsCache.put(rawTime, result); } } }
На этом второй шаг завершается. У нас получилось временно сохранить RAW-данные, данные CaptureResult и создать DNG file, не мешая сессии работать.
Мы обрабатываем и отправляем в ZIP DNG-файлы в произвольном порядке. Нас не интересует в каком порядке они выходят после обработки. Архив перед употреблением в монтажной программе, будет распакован, файлы будут отсортированы по названиям. Поэтому на начальном этапе, для тестов, нам достаточно вписать таймкод в название.
Шаг третий.
Нам осталось запустить процесс складывания файлов в ZIP. Для этого мы создаем очередь для DNG файлов и отдельную ветку, в которой файлы будут подготавливаться и сохраняться в ZIP.
LinkedBlockingQueue zipQueue; zipQueue = new LinkedBlockingQueue<>(4);
Для работы с ZIP мы используем ZipOutputStream zipOutputStream.
// открываем стрим файла OutputStream stream = resolver.openOutputStream(fileUri); // оборачиваем его в BufferStream, чтобы данные скидывались не сразу, а // чуть-чуть накапливались FileOutputStream bos = new BufferedOutputStream(stream); // оборачиваем в BufferStream в ZipOutputStream zipOutputStream = new ZipOutputStream(bos); // Настройки, чтобы не происходило сжатия zipOutputStream.setMethod(ZipOutputStream.STORED); zipOutputStream.setLevel(Deflater.NO_COMPRESSION);
А дальше запускаем ветку для работы с ZipOutputStream.
zipWriterThread = new Thread(() -> { try { while (RECORDING_STARTED || !zipQueue.isEmpty()) { DngZipPacket packet = null; try { packet = zipQueue.poll(300, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } if (packet != null) { try { ZipEntry entry = new ZipEntry(packet.entryName); entry.setSize(packet.dngData.length); entry.setCompressedSize(packet.dngData.length); CRC32 crc = new CRC32(); crc.update(packet.dngData); entry.setCrc(crc.getValue()); zipOutputStream.putNextEntry(entry); zipOutputStream.write(packet.dngData, 0, (int) packet.dngData.length); zipOutputStream.closeEntry(); } catch (IOException e) { } } if (!RECORDING_STARTED && zipQueue.isEmpty()) { break; } } } finally { try { if (zipOutputStream != null) { zipOutputStream.finish(); zipOutputStream.close(); } } catch (IOException e) { } zipOutputStream = null; } }, "ZipWriterThread"); zipWriterThread.start();
Обращаю внимание, что мы для сохранения в ZIP не используем многопоточность, чтобы не было соревнования за создание записей. Каждый пакет должен быть сохранен отдельно только со своими данными. Нельзя, чтобы после putNextEntry туда вписалось несколько DNG-файлов.
Выше мы установили настройки «без сжатия», потому что это самые легкие настройки для процессора. Для начала нам и этого достаточно. А потом окажется, что это единственно возможное.
Вот и весь механизм записи RAW-видео, как это делают «взрослые» камеры. Давайте повторим его уже на устройствах.
А теперь непосредственно к опытам!
Для проведения опытов я взял несколько достаточно свежи�� и мощных смартфонов на OC Android: Samsung S24 Ultra, Xiaomi 14 Ultra, Sony Xperia 5 mk IV и Samsung S25 Ultra.
Размер одного RAW буфера - это 24-25 Мегабайт при примерном разрешении сенсора 12 Мп.
Запускаем эксперимент и что мы видим:
Samsung S24 Ultra не справляется с задачей даже при скорости 24 к/с.
Xiaomi 14 Ultra при скорости 24 к/с не справляется вовсе (хотя под “справляется” мы даже допускаем наличие нескольких выпавших кадров).
Sony Xperia 5 IV вообще роняет скорость до 10-13 кадров в секунду, т.е. тоже не справляется.
Напомню, эти девайсы уж точно не назовешь слабыми. Но они не справляются с записью RAW-видео.

А теперь давайте посмотрим более детально на то, как в моем тесте проявил себя один из флагманов этого года — Samsung S25 Ultra.
Начинается все просто прекрасно, однако когда размер файла приближается к 5 - 8 Гб, что в эквиваленте всего 10 секунд записи, флагманская карета превращается в тыкву.
На старте:
создание DNG 20-25 мс
запись в ZIP-файл 10-15 мс
Уже видно, что суммарно весь процесс занимает больше 33 мс, необходимых для бесперебойной работы. Дальше происходит накопительный эффект, и время обработки существенно меняется:
создание DNG 40-50 мс
запись в ZIP-файл 10-15 мс.
Выводы из опытов
Опыты показали, что сжать файл на лету задача сложная, и с ней не справился ни один из испытуемых аппаратов. И если даже премиальные флагманы из мира Андроид не справляются с этой задачей, очевидно, что Андроид все еще не готов к съемке RAW‑видео.
А как же оптимизация (костылизация)?
Была у меня мысль о том, что некоторые девайсы умеют выдавать RAW10, и это было бы неплохим подспорьем для оптимизации процесса и позволило бы существенно снизить размер одного кадра: с 25 Мб до 15 Мб. Но оказалось, что нативный DNGCreator работает только с 16-битным RAW_SENSOR.
В остальном же методы оптимизации очевидны.
Сначала мы должны срезать пустые биты, т.к. большинство сенсоров у нас 10-битные, то 6 старших бит можно отрезать.
В случае, если система поддерживает RAW12, я бы задумался о том, чтобы отрезать только 4 бита.
Следующий шаг — это использование кропа. Можно уменьшить кадр или усреднить через складывание значений и получить на выходе 1080p вместо 2160p. Но для всего этого придется придумывать свой контейнер, который впоследствии должен иметь свой распаковщик для Windows и Mac.

Но всё это усложняет задачу в разы, а заодно и подводит нас к еще одному важному выводу:
Трудозатраты на оптимизацию и та разница в качестве, которую дает RAW в сравнении с правильно снятым YUV (h265/h264), не говорят в пользу RAW.
А если вспомнить, что мы все же говорим о мобильных устройствах, в которых крайне важным ресурсом являются и место на диске, и расход батареи, да и возможность снимать на Андроид‑смартфоны не за все деньги мира — то съемка в Log гамма‑кривых оказывается интереснее во всех отношениях.
Доверяем, но проверяем!

В свежем обновлении видеокамеры mcpro24fps я решил открыть доступ к данной функции в режиме Лаборатории для всех пользователей. За 2 месяца работы над RAW-видео параллельно написанию статьи мне удалось внедрить некоторые оптимизации и улучшения в код. Все, кто не хочет писать свое собственное приложение, могут самостоятельно активировать Запись RAW и провести все эксперименты прямо на своем Андроид смартфоне. Правда, для этого придется поддержать нас покупкой приложения, к примеру, в Google Play или RuStore.
Важно понимать: работоспособность всех функций в режиме Лаборатории не гарантирована. Со временем они могут исчезнуть из приложения, видоизмениться или же потребовать дополнительной оплаты (In-app покупки, подписка). Активируете их исключительно под вашу ответственность.
Вместо послесловия
Спасибо, что дочитали! В комментариях я готов прочитать всё, что только придет вам в голову: от критики моего мнения, до каких-либо решений, касаемо оптимизации записи RAW-видео. Не обещаю, что ваши предложения и идеи будут использованы в дальнейшем, но они могут навести на интересные мысли.
