Воспроизведение и редактирование видео с AVFoundation. Часть 1. Ассеты простые

nuftt_p5f6c3nkistrqzjsnkt6i.png

C 2023 по 2025 год мы с командой развивали модуль создания контента в одном из приложений по просмотру коротких видео в России. Я присоединился к ней в середине пути и успел глубоко изучить как сам фреймворк AVFoundation, так и задачи, которые с помощью него можно решать. Хочу поделиться полученным опытом.

Представляю первую статью из цикла, который посвящён идеям воспроизведения и редактирования медиа с использованием AVFoundation.

Цикл поможет программистам без опыта работы с AVFoundation получить минимальную теоретическую базу (подчеркну — не практическую, для этого в светлом будущем я планирую выпустить отдельный цикл), чтобы создавать свои видеоплееры и видеоредакторы. В некоторых разделах я буду кратко упоминать ключевые моменты, а в других — подробно разбирать тонкости.

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

  • Часть 1. Ассеты простые

  • Часть 2. Ассеты продвинутые

  • Часть 3. Импорт, экспорт и воспроизведение ассетов

  • Часть 4. Собственный алгоритм отрисовки видеокадров ассетов

В этой статье рассмотрим главный объект работы с медиа — простой ассет.

Ассеты

[! definition]
Ассет — локальный или удалённый мультимедийный файл или объект, который содержит различные потоки данных: видео, аудио, субтитры, метаданные и пр.

Базовый класс для работы с мультимедиа в AVFoundation — это AVAsset. Создать его можно только пустым, то есть без какого-то либо контента (устаревшие методы не учитываем), а его редактирование не поддерживается. Класс содержит базовые методы и свойства, используемые в подклассах (расскажу о них в этой и следующих статьях), в которых уже возможны и интересная инициализация, и редактирование.

Для работы с существующими ассетами, которые можно получить по удалённой или локальной ссылке, в AVFoundation реализован класс AVURLAsset: AVAsset.

let localOrRemoteUrl: URL = ...

// создание ассета из удалённого или локального файла по ссылке
let asset = AVURLAsset(url: localOrRemoteUrl)

Получение свойств ассетов

Сразу проговорю, что предпочтительный способ получения свойств любого ассета — асинхронный. Подробные причины описаны в документации Loading media data asynchronously. Сейчас достаточно будет сказать, что вычисление того или иного свойства медиа зависит от многих факторов: мощности девайса, свободного количества оперативной памяти, формата медиа. Для асинхронной работы с любыми свойствами AVAsset имеет специальные методы:

// загрузка свойств
func load(_ property: AVAsyncProperty) async throws -> T

// отмена всех загрузкок свойств
func cancelLoading()

Углубляться в его реализацию не будем, проиллюстрируем примерами:

let asset: AVAsset

// загрузка одного свойства
let creationDate = try await asset.load(.creationDate)

// загрузка кортежа
let (commonMetadata, duration, isPlayable) = try await asset.load(
	.commonMetadata,
	.duration,
	.isPlayable
)

[! warning]
Я рекомендую избегать получения свойств в синхронном режиме (например: asset.creationDate или asset.duration). Во-первых, вы получите много depricated предупреждений. Во-вторых, это может тормозить работу вашего приложения.

Обозрение свойств ассетов

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

var duration: CMTime { get }
var isPlayable: Bool { get } // не любой ассет может быть проигран

Обратите внимание, длительность здесь представлена не в Float или Double, а в CMTime. Это первый раз, когда мы сталкиваемся с этой структурой. Давайте сразу разберём её подробнее.

CMTime — это структура из CoreMedia, которая используется в AVFoundation для представления времени. Позволяет избежать потерь точности, присущих Float и Double, благодаря рациональному (дробному) представлению времени.

Основных свойств у этой структуры два:

var value: CMTimeValue // Числитель (количество единиц времени)
var timescale: CMTimeScale // Знаменатель (сколько таких единиц в одной секунде)

Фактически CMTime говорит: «Я представляю время как value / timescale секунд». Например, если let time = CMTime(value: 3000, timescale: 1000), то это означает 3 секунды, потому что 3000 / 1000 = 3.0 (time.seconds // 3.0).

Почему именно так? Дело в том, что в медиафайлах кадры и аудиосемплы записываются не в случайные моменты времени, а с определённой частотой, например, 30 кадров в секунду. Использование дробных значений помогает точно представлять и сравнивать такие временные метки без ошибок округления. Более подробно о CMTime можно узнать в этой статье на Хабре.

[! warning]
Исходя из вышесказанного, не рекомендуется в работе с медиа писать код, оперирующий секундами в Double или Float. Особенно при редактировании медиа: существует большой риск ошибок, вызванных округлением типов данных.

Закончим отступление о времени и вернёмся к ассетам. Если вы любознательны и захотите ознакомиться с документацией по AVAsset, то увидите следующие и аналогичные свойства:

// скорость проигрывания
var preferredRate: Float { get }
// громкость аудио
var preferredVolume: Float { get }
// трансформация видео
var preferredTransform: CGAffineTransform { get }

С этими свойствами следует работать аккуратно. Не всегда очевиден алгоритм, по которому они определяются. Особенно в продвинутых ассетах, о которых вы узнаете в следующей статье. Рекомендуется обращаться к конкретным свойствам конкретных потоков ассета — об этом расскажу в блоке «Треки ассетов».

Пример получения данных видео, созданного в Blackmagic Design DaVinci Resolve Studio:

let asset: AVAsset

try await asset.load(.duration).seconds
// 5.037265625

try await asset.load(.isPlayable)
// true

try await asset.load(.preferredRate)
// 1.0

try await asset.load(.preferredVolume)
// 1.0

try await asset.load(.preferredTransform)
// CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 0.0, ty: 0.0)

Выше мы упомянули некую трансформацию видео. Обсудим её в блоке «Треки ассетов» и в следующей части, но, как видно из снипетов, её тип — CGAffineTransform. Это афинное преобразование из курса математики старшей школы. На случай, если тема забылась, очень рекомендую освежить её в памяти — в некоторых местах без неё будет тяжело. Можно обратить внимание на источники: Что за зверь — аффинные преобразования? (Хабр), Вертел я ваши UIView (Хабр), CGAffineTransform (Apple Developer).

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

09djdoqylzvhuugdrfbdkk3lz4s.gif

Метаданные ассета

Важная часть ассета — это метаданные.

[! definition]
Метаданные — это информация, описывающая характеристики мультимедийных данных, таких как длительность, формат, разрешение, частота кадров, кодек, автор и пр.

Метаданные полезны, например, для сбора статистики по обработке и просмотру видео.

[! hint]
Метаданные аудио и видео хранятся в метаданных потоков данных, а не ассета, об этом расскажу в блоке «Треки ассетов».

Для работы с метаданными AVFoundation предоставляет нам класс AVMetadataItem. Особенно интересными свойствами этого класса являются:

var key: (any NSCopying & NSObjectProtocol)? { get }
var commonKey: AVMetadataKey? { get } // 
var value: (any NSCopying & NSObjectProtocol)? { get }

С value всё очевидно — значение конкретных метаданных. Ниже мы рассмотрим пример. Разберёмся же с key и commonKey, здесь всё немного сложнее. key, конечно, отражает суть метаданных, но в нём может быть очень много всего и в разных форматах (обратите внимание на тип данных в определении) — зависит от того, что это за медиафайл, кто его создал, как и прочее. commonKey — это кастинг значения key до конкретного типа данных AVFoundation — AVMetadataKey. С конкретным списком вариантов работать всегда куда легче. Однако в сложных случаях всё равно придётся работать с key. Зная ключи, можно легко производить поиск нужных значений. Вот часть вариантов AVMetadataKey:

extension AVMetadataKey {
    static let commonKeyTitle: AVMetadataKey // заголовок
    static let commonKeyCreator: AVMetadataKey // создатель
    static let commonKeyLanguage: AVMetadataKey // язык
    static let commonKeyDescription: AVMetadataKey // описание
    static let commonKeyAuthor: AVMetadataKey // автор 
    static let commonKeyCreationDate: AVMetadataKey // дата создание
...

Получить доступ к метаданным можно через загрузку свойства:

var metadata: [AVMetadataItem]

Примеры (часть информации была скрыта за ..., чтобы избыточно не расширять снипеты):

let asset: AVAsset
try await asset.load(.metadata)

Метаданные видео, записанного на iPhone (смотрите на указатели <-):

[
    , // <- производитель

    , //  <- устройство

    , // <- версия OS

     // <- время записи
]

Метаданные видео, созданного в DaVinci Resolve Studio (смотрите на указатель <-):

[
     // <- программа создания
]

Треки ассетов

[! definition]
Трек — последовательность мультимедийных данных одного типа, например, видео, аудио или субтитры, которая ассоциирована с определённым потоком в медиапродукте.

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

[! hint]
Трек может быть частью одного или нескольких ассетов.

AVAsset может содержать несколько треков. Каждый трек может быть одним из типов, которые описываются структурой AVMediaType:

extension AVMediaType {
	// <- Наиболее часто используемые типы
    static let video: AVMediaType
    static let audio: AVMediaType
    static let subtitle: AVMediaType
	// ->

    static let text: AVMediaType
    static let closedCaption: AVMediaType
    static let timecode: AVMediaType
    static let metadata: AVMediaType
    static let muxed: AVMediaType
    static let haptic: AVMediaType
}
ydzcixnkjvgugvpprnex9rxwdzu.png

В AVFoundation ассеты трека описывает класс AVAssetTrack .

Получение треков

Для получения треков из ассета можно использовать методы:

// для загрузки трека по ID
func loadTrack(
	withTrackID trackID: CMPersistentTrackID
) async throws -> AVAssetTrack?

// для загрузки треков с определённым медиатипом
func loadTracks(
	withMediaType mediaType: AVMediaType
) async throws -> [AVAssetTrack]

Также можно получить все треки через загрузку свойства:

var tracks: [AVAssetTrack] { get }

Вот какие треки можно найти в видео, записанном на iPhone:

try await asset.load(.tracks)

"",
"",
"",
"",
""

Обозрение свойств треков

У AVAssetTrack достаточно много свойств, детально с ними можно ознакомиться в документации. Здесь же перечислим наиболее часто используемые:

// ID трека, должен быть уникальным внутри ассета
var trackID: CMPersistentTrackID { get }

// временной диапазон трека внутри ассета
var timeRange: CMTimeRange { get }

// количество бит в секунду для трека
var estimatedDataRate: Float { get }

// частота кадров (для видеотрека)
var nominalFrameRate: Float { get }

// <- рассмотрим ниже
var metadata: [AVMetadataItem] { get } // метаданные
var naturalSize: CGSize { get } // исходный размер
var preferredTransform: CGAffineTransform { get } // исходная трансформация
var segments: [AVAssetTrackSegment] { get } // сегменты
// ->

[! note]
Выше мы уже говорили о CMTime — важной структурой выражения времени в CoreMedia и AVFoundation. В этом же моменте мы встречаемся с не менее важной структурой — CMTimeRange.

CMTimeRange — структура, описывающая отрезок времени в медиапотоке. Используется для указания диапазонов, например, при обрезке видео или аудио.

CMTimeRange состоит из двух основных свойств:

var start: CMTime // начало диапазона
var duration: CMTime // длительность диапазона

Фактически, CMTimeRange описывает отрезок времени: «Я начинаюсь в start и длюсь duration секунд».

[! hint]
Подсветим, что у CMTimeRange есть вычисляемое свойство end: CMTime.

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

Иногда необходимо не просто указать диапазон, но и сказать, откуда и куда он будет перенесён. Для этого есть CMTimeMapping, который состоит из двух CMTimeRange:

var source: CMTimeRange // исходный временной диапазон
var target: CMTimeRange // новый диапазон, куда переносится исходный

[! hint]
Я рекомендую использовать структуры CMTime, CMTimeRange и CMTimeMapping в своих проектах, чтобы не изобретать колесо для описания длительностей при работе с медиа и не кастить простые или собственные типы в типы AVFoundation и из них.

Свойства треков: исходные размер и трансформация

Когда разобрались со структурами времени, разберём отдельно исходный размер и исходную трансформацию:

var naturalSize: CGSize { get }
var preferredTransform: CGAffineTransform { get }

Исходный размер видеопотока — это размеры видео в пикселях без учёта трансформаций — «как есть», без всяких изменений, которые могут быть вызваны его ориентацией или поворотом (по этой причине здесь могут быть отрицательные значения!).

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

Пример naturalSize и preferredTransform видео, которое пользователь записывал на iPhone вертикально:

let videoTrack = try! await asset.loadTracks(withMediaType: .video).first!

try await videoTrack.load(.naturalSize)
// NSSize: {1920, 1080} <- видим, что naturalSize горизонтальный, а не вертикальный

try await videoTrack.load(.preferredTransform)
// CGAffineTransform: {{0, 1, -1, 0}, {1080, 0}} <- видим, что трансформация превращает горизонтальное видео в вертикальное (как и записывал пользователь)

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

let videoTrack: AVAssetTrack

try await videoTrack.load(.naturalSize)
	.applying(try await videoTrack.load(.preferredTransform))
// (-1080.0, 1920.0)

Но есть нюанс. Для видео, указанного выше, такое вычисление даст (-1080.0, 1920.0). Есть много исторических и технических причин, почему так происходит. Не будем вдаваться в них — это отдельная сложная тема, которая не даст преимуществ при программировании непрофессиональных видеоредакторов и видеопроигрывателей. Ограничимся суждением: чтобы узнать размер видеотрека, нужно взять его исходный размер, применить трансформацию и взять из неё абсолютные значения:

let videoTrack: AVAssetTrack

let transformedNaturalSize = try await videoTrack.load(.naturalSize)
	.applying(try await videoTrack.load(.preferredTransform))
let absoluteSize = CGSize(
	width: abs(transformedNaturalSize.width),
	height: abs(transformedNaturalSize.height)
)

Детальнее о preferredTransform и его применении мы поговорим в следующей статье.

[! warning]
У вас может возникнуть идея написать вычисляемое свойство или функцию вроде такой:

 extension AVAsset {
	func getAbsoluteNaturalSize() async throws -> CGSize {
        let transformedNaturalSize = try await loadTracks(
 	       withMediaType: .video
 	   ).first!
            .load(.naturalSize)
            .applying(try await load(.preferredTransform))
            
        return CGSize(
            width: abs(transformedNaturalSize.width),
            height: abs(transformedNaturalSize.height)
        )
    }
}

Действительно, решение выглядит естественным. Более того, идею такого решения можно встретить на StackOverflow. Однако делать так не стоит, потому что для наследника AVAsset — AVComposition, о котором мы узнаем во второй части, такое решение не будет корректным во всех случаях.

Свойства треков: метаданные

Обсудим метаданные треков. В них можно найти более подробную информацию, чем в метаданных ассета. Эти данные также могут помочь в сборе статистики и багфиксах.

Прилагаю пример метаданных видеотрека, записанного на iPhone (смотрите на указатели <-):

let videoTrack = try await asset.loadTracks(withMediaType: .audio).first!
try await videoTrack.load(.metadata)

[
    , // <- модель камеры
    
     // <- фокусное расстояние
]

Также выше мы договорились рассмотреть свойство сегментов:

var segments: [AVAssetTrackSegment] { get }

Сегменты — кирпичики, из которых складывается трек. Для работы с ними существует класс AVAssetTrackSegment. У него всего два свойства — сопоставление временных отрезков и индикатор пустоты (о необходимости пустых сегментов мы будем говорить в следующей статье):

var timeMapping: CMTimeMapping { get } // сопоставление временных отрезков
var isEmpty: Bool { get } // индикатор пустоты

Рассмотрим на примере. Для самого обычного видео сегменты видеотрека будут выглядеть так:

(
    <
        AVAssetTrackSegment: 0x60000291ebc0,
        // исходный временной диапазон медиапотока (всегда начинается с нуля!)
        source = {{0/12800 = 0.000}, {64000/12800 = 5.000}},
        // временной диапазон, на котором расположили исходный медиапоток
        target = {{0/12800 = 0.000}, {64000/12800 = 5.000}}
    >
)

Для простых видео и аудио source и target всегда будут совпадать. Про сложные случаи вы узнаете из следующих статей.

Заключение

Мы подошли к завершению первой части цикла. В ней узнали, что такое простые ассеты и из чего они состоят, научились открывать их по локальной и удалённой ссылке, базово анализировать через метаданные и свойства. Пока у вас не должно появиться понимания — что делать со всеми этими знаниями. Оставим это на будущие статьи.

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

Habrahabr.ru прочитано 6248 раз