Уверен, что многих возмутит уже само название этой статьи. А некоторые сразу же побегут в комментарии указывать на приложение, которое “смогло”. Но не стоит спешить, друзья!
Сегодня вам предстоит увлекательное путешествие по стыку технологий, кода и технических решений, которые и расскажут вам то, о чем адепты съемки мобильного 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-видео. Не обещаю, что ваши предложения и идеи будут использованы в дальнейшем, но они могут навести на интересные мысли.