Многие наверняка уже имели опыт реализации прямой трансляции в мобильных приложениях, и я в том числе был уверен, что сделать фичу не займет много времени с помощью таких библиотек как: video_player / media_kit / vlc.

Практически все плееры предлагают одинаковую модель использования: “открыть по ссылке”:

  • videoController.network(dataSource)

  • videoController.networkUrl(dataSource)

При этом на вебе трансляция уже была реализована и работала в двух режимах: live-просмотр и перемотка назад. Поэтому первым шагом стало изучение того, как именно этот функционал устроен в браузере.

Через DevTools было определено, что с одной стороны, присутствовал HLS-плейлист .m3u8, а с другой — активное WebSocket соединение, по которому в реальном времени летели бинарные данные.

HLS — коммуникационный протокол для потоковой передачи медиа на основе HTTP, в основе работы которого лежит принцип разбиения цельного потока на небольшие фрагменты, последовательно скачиваемые по HTTP. Поток непрерывен и теоретически может быть бесконечным.

https://www.100ms.live/blog/hls-low-latency-streaming

Попытка использовать HLS напрямую в мобильных плеерах действительно позволяла воспроизвести трансляцию, однако задержка стабильно составляла порядка 15–22 секунд. Для live-режима такие значения неприемлемы.

Исходя из этого, было установлено - HLS в данном случае используется не как основной механизм прямой трансляции, а скорее как вспомогательный инструмент для просмотра записи с возможностью перемотки.

Основной же live-поток на вебе реализован через WebCodecs — API для работы с аудио- и видеокодеками напрямую, доступного в браузере “из коробки”. С этого момента началось изучение того, как реализовать трансляцию на мобильном клиенте.


Что приходит с бэкенда

Первым делом, необходимо собрать вводные. Погрузившись более детально было установлено, что сервер по веб-сокету присылает потоково чанки трансляции (в рамках статьи под чанком будет подразумеваться один закодированный видеокадр), структура которых показана ниже:

class WebCodecChunk {
  final String channelId;
  final String data; 			// base64
  final int pts;
  final int duration;
  final FrameType frameType; 	// video / audio
  final bool key;			    // is key frame
  final DateTime timestamp;
}

- где data оказалось строкой base64. Каждый 30-й кадр являлся ключевым (IDR).

По веб-сокету от сервера также отдельно получены параметры трансляции:

Видео:

где avc1.641028 означает, что это H.264/AVC High Profile, Level 4.1.

Аудио:

  • codecString: mp4a.40.2

  • sampleRate: 48000

  • channelsCount: 2

Далее, начался поиск возможных готовых решений для воспроизведения прямой трансляции на веб-кодеках...


Готовые решения?

Скрытый текст

(спойлер: нет)

Есть прямая трансляция, кадры которой приходят потоково через WebSocket.
Каждое сообщение содержит сырой H.264, закодированный в base64.
Это не файл, не HLS, не RTMP, а последовательность NAL-юнитов, где:

● с определенной периодичностью приходит I-кадр (ключевой кадр, IDR)

● между I-кадром идут P/B-кадры

P/B-кадры невозможно декодировать без предыдущего состояния декодера

● важна минимальная задержка

, где GOP - это логическая группа видеокадров, внутри которой кадры зависят друг от друга (Group of Pictures)

Логичный первый шаг в такой ситуации — проверить, существуют ли уже готовые решения.

Я рассмотрел несколько пакетов:

  1. ffmpeg_kit_flutter_new

    — представляюет собой обёртку над FFmpeg / FFprobe, позволяющую запускать FFmpeg-команды из Flutter.

    В рамках типового использования пакет:

    - запускает FFmpeg-команду
    - выполняет её
    - завершает сессию

    В моём случае данные потоково приходили по WebSocket в виде base64-строк, содержащих отдельные фрагменты видеопотока.

    Это означало, что каждый такой чанк необходимо было передавать в FFmpeg как отдельную операцию декодирования через ffmpeg_kit_flutter_new.
    В результате для каждого кадра фактически запускалась новая FFmpeg-сессия, что делало такой подход слишком затратным для realtime-трансляции.

    Сразу было установлено, что в такой модели контекст декодера не удерживается между вызовами, и стабильно удаётся получать изображение только из ключевых кадров.

    Фактически это превращало “прямую трансляцию” в слайдшоу с частотой примерно 1 кадр на GOP.


    Вывод:

    Пакет отлично подходит, если нужно:

    - перекодировать файл
    - нарезать видео
    - вытащить кадры из готового источника
    - выполнить “тяжёлую” медиа-операцию одной ко��андой

    Но не для сценария:
    - “декодировать H.264 NAL-ы, приходящие по WebSocket, с минимальной задержкой”.

    Дополнительно стоит учитывать, что оригинальный FFmpegKit был официально переведён в статус retired, а ffmpeg_kit_flutter_new является форком. Это не приговор, но фактор риска при долгосрочной поддержке.

  2. h264

    Согласно описанию, это плагин для Flutter, который декодирует необработанные кадры (H264/H265 IDR) в растровые изображения.

    Судя по описанию и примерам использования, пакет ориентирован на декодирование отдельных ключевых кадров и не позиционируется как потоковый декодер, способный обрабатывать последовательность зависимых кадров.

    В примерах входные данные передаются через файл (asset), а результатом работы является сформированное изображение. Такой подход хорошо подходит для оффлайн-декодирования отдельных кадров, однако плохо применим для live-трансляций, где требуется непрерывная обработка видеопотока с сохранением состояния декодера.

  3. FlutterQuickVideoEncoder

    Несмотря на название, FlutterQuickVideoEncoder решает обратную задачу.

    Библиотека предназначена для кодирования видео: она принимает последовательность raw RGB(A)-кадров (и опционально PCM-аудио) и собирает из них видеоролик в формате MP4.

Итог

С точки зрения задачи “прямая трансляция H.264 через WebSocket с минимальной задержкой”:

  • ffmpeg_kit_flutter_new — FFmpeg как командный инструмент, но не живой декодер

  • h264 — декодирование одиночных IDR-кадров, а не потока

  • FlutterQuickVideoEncoder — энкодер, а не декодер

Готового решения для вывода прямой трансляции на WEB-codec-ах “из коробки” найти не удалось. Это и стало отправной точкой для изобретения велосипеда.


Отрицание, торг, принятие

Прошло пару недель, и стадия отрицания сменилась принятием. Стало ясно, что готового решения под этот формат входных данных нет, и задачу придётся решать самостоятельно.

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

Было рекомендовано перейти на MessagePackHubProtocol, чтобы получать данные сразу в бинарном виде, без base64-декодирования на клиенте. Ранее сервер отправлял кадры в формате base64-строк, что требовало дополнительного декодирования на клиенте. Переход на бинарный протокол позволил исключить этот шаг и сэкономить до 3-5 миллисекунд на обработке каждого чанка.

Далее рассматривались два варианта реализации:

  • нативная реализация на iOS/Android через platform channels

  • реализация C++ библиотеки для транскодинг потока с FFmpeg

Первой платформой был выбран iOS. Процесс изучения и реализации функционала занял несколько дней, где на выходе получилась более-менее рабочая версия вывода только видео-кадров.

Далее, было принято решение добиться примерно того же для андроида. Больше двух недель попыток не привели к результату.

По совету тимлида я сделал шаг назад и перешел ко второму варианту, который даёт:

  • единый код

  • одинаковое поведение на iOS и Android

Имея опыт разработки на плюсах, а также с ffi я принялся за работу и спустя несколько недель добился вывода прямой трансляции в приложении с задержкой менее 1 сек. на обоих платформах.

Дальше будет представлена схема данных и фрагменты реализации фичи:

Для оптимизации производительности был создан отдельный изолят, в котором устанавливалось соединение с сервером через SignalR:

    _signalRConnection =
        HubConnectionBuilder()
            .withUrl(connectionUrl, options: connectionOptions)
            .withHubProtocol(MessagePackHubProtocol())
            .build();

После получения параметров трансляции от сервера запускалось прослушивание стрима данных и выполнялась инициализация нативной библиотеки. Для работы с библиотекой на стороне Flutter был реализован FFI-слой - класс FfiDecoder, который инкапсулирует вызовы нативных функций и управляет жизненным циклом декодеров.

final DynamicLibrary _videoDecoderLib = () {
  if (Platform.isIOS) {
    final lib = DynamicLibrary.process();
    return lib;
  }
  if (Platform.isAndroid) {
    final lib = DynamicLibrary.open('libh264_stream_decoder.so');
    return lib;
  }
  throw UnsupportedError('Unsupported platform');
}();


class FfiDecoder {
  static FfiDecoder? _instance;

  factory FfiDecoder() {
    return _instance ??= FfiDecoder._internal();
  }

  late final Pointer<Void> _videoDecoderPtr;
  late final Pointer<Void> _audioDecoderPtr;

  FfiDecoder._internal() {
    _setLogCallback(_logCallbackPointer);
    setNativeLogLevel(FfiDecoderLogLevel.warning);
    _videoDecoderPtr = _createDecoder();
    _audioDecoderPtr = _createAudioDecoder();
  }
  ...
}

Для работы с CPP-типами данных было необходимо реализовать binding:

typedef _CreateDecoderC = Pointer<Void> Function();

typedef _CreateAudioDecoderC = Pointer<Void> Function();

typedef _ReleaseDecoderC = Void Function(Pointer<Void>);

typedef _ReleaseAudioDecoderC = Void Function(Pointer<Void>);

typedef _PushPacketToDecoderC =
    Void Function(
      Pointer<Void>,  // decoder handle
      Pointer<Uint8>, // input data
      Int32,          // input size
      Int64,          // pts in microseconds
      Bool,           // isKeyFrame
    );


final _createDecoder =
    _videoDecoderLib
        .lookup<NativeFunction<_CreateDecoderC>>('create_decoder')
        .asFunction<Pointer<Void> Function()>();

final _createAudioDecoder =
    _videoDecoderLib
        .lookup<NativeFunction<_CreateAudioDecoderC>>('create_audio_decoder')
        .asFunction<Pointer<Void> Function()>();

final _releaseDecoder =
    _videoDecoderLib
        .lookup<NativeFunction<_ReleaseDecoderC>>('release_decoder')
        .asFunction<void Function(Pointer<Void>)>();

final _releaseAudioDecoder =
    _videoDecoderLib
        .lookup<NativeFunction<_ReleaseAudioDecoderC>>('release_audio_decoder')
        .asFunction<void Function(Pointer<Void>)>();

final _pushPacketToDecoder =
    _videoDecoderLib
        .lookup<NativeFunction<_PushPacketToDecoderC>>('push_packet_to_decoder')
        .asFunction<
          void Function(Pointer<Void>, Pointer<Uint8>, int, int, bool)
        >();

И, на стороне плюсовой библиотеки также было необходимо обозначить видимость вызываемых методов:

// ffi_interface.cpp
#include "ffi_interface.h"
#include "decoder_h264.h"
#include "decoder_aac.h"
#include "logger.h"
#include <cstdlib>
#include <cstring>

extern "C"
{
    __attribute__((visibility("default")))
    __attribute__((used))
    DecoderHandle
    create_decoder()
    {
        return new DecoderH264();
    }

    __attribute__((visibility("default")))
    __attribute__((used))
    AudioDecoderHandle
    create_audio_decoder()
    {
        return new DecoderAAC();
    }

    __attribute__((visibility("default")))
    __attribute__((used)) void
    release_decoder(DecoderHandle handle)
    {
        if (!handle)
            return;
        delete static_cast<DecoderH264 *>(handle);
    }

    __attribute__((visibility("default")))
    __attribute__((used)) void
    release_audio_decoder(AudioDecoderHandle handle)
    {
        if (!handle)
            return;
        delete static_cast<DecoderAAC *>(handle);
    }

    __attribute__((visibility("default")))
    __attribute__((used)) void
    push_packet_to_decoder(
        DecoderHandle handle,
        const uint8_t *data,
        int size,
        int64_t pts,
        bool is_key_frame)
    {
    ...
}

Без наличия ключевого кадра FFmpeg не может корректно декодировать P-кадры. Так как мы можем присоединиться к трансляции в рандомный момент времени, был реализован механизм отбрасывания первых не ключевых кадров.

При поступление IDR-кадра последующие видео и аудио-чанки буферизировались и по очереди отправлялись на декодирование в нативную библиотеку.

Видео и аудио обрабатывались похожим образом: чанки по очереди отправлялись в декодер, а на выходе возвращались уже в “сырых” форматах (RGBA для видео и PCM для аудио). Аудио-чанки приходили от сервера как raw AAC без ADTS-заголовков, потому, для успешного преобразования в PCM-байты нужно было добавить их вручную:

  Uint8List _aacWithAdtsHeader(
    Uint8List rawAac,
    int sampleRate,
    int channelsCount,
  ) {
    final frameLength = rawAac.length + 7;
    final adts = Uint8List(frameLength);

    // ADTS fixed header
    adts[0] = 0xFF; // Sync byte 1
    adts[1] = 0xF1; // Sync byte 2 + protection absent

    // ADTS variable header
    // Profile: AAC LC (2-1=1), Sample Rate Index, Channel Config
    final sampleRateIndex = _getSampleRateIndex(sampleRate);
    adts[2] = (0x01 << 6) | (sampleRateIndex << 2) | (channelsCount >> 2);
    adts[3] = ((channelsCount & 0x3) << 6) | ((frameLength >> 11) & 0x03);
    adts[4] = (frameLength >> 3) & 0xFF;
    adts[5] = ((frameLength & 0x7) << 5) | 0x1F;
    adts[6] = 0xFC;

    adts.setRange(7, frameLength, rawAac);
    return adts;
  }

При успешном преобразовании данные возвращались в основной изолят, в котором происходила последующая обработка и вывод видео/аудио.

Чтобы минимизировать копирование крупных буферов при передаче данных между изолятами начальные данные из Uint8List упаковывались в TransferableTypedData. В основном изоляте, при обработке сообщений выполнялось обратное преобразование bytes.materialize().asUint8List().

class RgbaFrame {
  final int width;
  final int height;
  final Uint8List bytes;

  const RgbaFrame({
    required this.width,
    required this.height,
    required this.bytes,
  });

  Map<String, Object?> toMap() => {
    'width': width,
    'height': height,
    'bytes': TransferableTypedData.fromList([bytes]),
  };

  factory RgbaFrame.fromMap(Map<String, Object?> map) => RgbaFrame(
    width: map['width'] as int,
    height: map['height'] as int,
    bytes: (map['bytes'] as TransferableTypedData).materialize().asUint8List(),
  );
}

На финальном этапе происходило последнее преобразование из rgba-буфера в Image:

  Future<Image> _decodeRgbaToImage(RgbaFrame rgbaFrame) async {
    final buffer = await ImmutableBuffer.fromUint8List(rgbaFrame.bytes);

    final descriptor = ImageDescriptor.raw(
      buffer,
      width: rgbaFrame.width,
      height: rgbaFrame.height,
      pixelFormat: PixelFormat.rgba8888,
    );

    final codec = await descriptor.instantiateCodec();
    final frame = await codec.getNextFrame();
    return frame.image;
  }

На базе полученного Image выполнялся вывод кадра трансляции:

class WebCodecVideoWidget extends StatelessWidget {
  final WebCodecController webCodecController;

  const WebCodecVideo({super.key, required this.webCodecController});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: webCodecController.frameValueNotifier,
      builder: (context, value, child) {
        return RawImage(image: value, fit: BoxFit.fill);
      },
    );
  }
}

На выходе аудиопоток уже находится в формате PCM и напрямую передавался в аудиосервис для воспроизведения:

  Future<void> play(Uint8List aacData) async {
    if (!_isInitialized) return;
    final value = aacData.buffer.asUint16List();
    await FlutterPcmSound.feed(PcmArrayInt16.fromList(value));
  }

Итог

В статье приведены ключевые фрагменты, так как полная реализация завязана на внутреннюю бизнес-логику проекта.

Репозиторий с нативной частью проекта доступен по ссылке.

Основная особенность заключается в том, что декодер реализован в виде нативной C++ библиотеки, которая удерживает контекст FFmpeg на протяжении всей трансляции. Каждый новый пакет передаётся в уже инициализированный декодер, который хранит SPS/PPS, reference frames и внутренние буферы. Это позволяет корректно декодировать P-кадры и обеспечивает минимальную задержку для вывода трансляции.

Ограничения текущего решения:

  • декодирование выполняется программно на CPU (без использования аппаратных декодеров)

  • при обработке 4K-трансляций высокая нагрузка на CPU может приводить к нагреву устройства и увеличению отставания от live-трансляции

Это решение не является единственным возможным, однако выбранный подход позволил добиться задержки менее одной секунды и обеспечить предсказуемую работу на обеих платформах — iOS и Android.

Если у вас есть вопросы или интерес к теме — можете написать мне лично. Буду рад обратной связи и обсуждению альтернативных подходов!