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

Я разберу лишь основные моменты, которые и убедили меня в том, что эффективная съемка RAW-видео на Андроид на сегодняшний день невозможна без “костылей” и ухищрений. Костылей, которые нивелируют все те преимущества RAW, которые так жаждут получить на своих смартфонах видеографы. Ухищрений, которые по итогу делают менее ресурсоемкие форматы записи видео на смартфоне даже более эффективными и качественными, чем RAW.

Да, будет интересно!

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

Но сначала представлюсь

Меня зовут Александр Трофимов, я программист и энтузиаст мобильной видеографии. А еще я разработчик уже довольно известного приложения профессиональной видеосъемки для Андроид-смартфонов mcpro24fps.

Что побудило меня написать эту статью?

Прежде всего, желание показать реальное положение вещей. Ведь сами понятия RAW-видео, как и Log-видео перекочевали на смартфоны из мира “взрослых” и больших камер. А маркетологи приложили все усилия к тому, чтобы пользователь считал, что его смартфон за 300 долларов уже давно снимает лучше профессиональной камеры за 300 тысяч долларов.
В процессе работы над своим приложением я провожу достаточно много времени в изысканиях и экспериментах со смартфонами самого разного класса. А еще я получаю фидбек от тысяч пользователей с их чаяниями и просьбами.

С момента написания предыдущих статей (тык), (тык) и (тык) мне удалось существенно прокачать Log-съемку и все, что с ней связано. В том числе, дать возможность адаптировать Log гамма-кривые от “взрослых” камер с большими сенсорами под маленькие сенсоры каждого конкретного смартфона.

И вот, казалось бы, эффективность Log на смартфоне доведена до предела, но..

ВСЕ ХОТЯТ еще и RAW!

Почему? А потому что:
“У меня вообще-то флагман, я деньги заплатил!”
“А у меня восемнадцать ядер в телефоне, внешний аккумулятор и карта памяти внешняя - должен всё тянуть!”
Ну и так далее..

Говоря о самых мощных процессорах, стопитсотмегапиксельных сенсорах многие как-то забывают об ограничениях наших с вами компактных Андроид-смартфонов. Ограничениях, которые мы и разберем подробно далее..

Что же такое RAW?

И начнем мы с самого простого — определения того, как выглядит RAW-видео на взрослых камерах, какие стандарты существуют. И здесь негде разбежаться. Их всего два:

  1. Старый добрый ZIP с бесконечным количеством кадров в формате DNG.

  2. MXF контейнер, с метаданными CinemaDNG и «колбасой» данных в формате DNG. Преимущество этого формата заключается в том, что здесь можно указать много всяких метаданных, включая скорость кадров, которые понимают монтажные программы.

Всё. Остальные подходы - это костыли и уловки, к которым индустрия видео не приучена. А значит, монтажные приложения не будут это поддерживать.

Погружаемся в код

Сейчас будет много кода и пояснений к нему. Те, кто не очень умеет в код, могут сразу переходить к разделу с практическими экспериментами!

Очень хотелось бы воспользоваться контейнером MXF, но, похоже, для этого придется писать свой Muxer, поддерживающий этот контейнер. Для этого надо прочитать документацию, понять ее, и правильно реализовать. На это надо достаточно много времени, а ниже мы поймем, что и это нас бы не спасло (хотя надежда остается до момента ее «убийства»). Отбрасываем этот вариант и возвращаемся к классике, которой пользуются разные видео-камеры среднего ценового сегмента.

И так, наша задача выглядит проще некуда. Разложим ее по шагам.

  1. Взять сырые данные с сенсора камеры.

  2. Сформировать на их основе файл DNG.

  3. Положить 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 готовый файл. Но нет. У нас две проблемы:

  1. CaptureResult в onCaptureCompleted приходит позже, чем приходит кадр в OnImageAvailableListener.

  2. 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-видео. Не обещаю, что ваши предложения и идеи будут использованы в дальнейшем, но они могут навести на интересные мысли.

Комментарии (0)