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

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).
Для иллюстрации работы афинных преобразований приведу такой пример. В нём мы сначала берём изначальную трансформацию, после чего накидываем на неё дополнительные преобразования в виде сжатия, сдвига и поворота:

Метаданные ассета
Важная часть ассета — это метаданные.
[! 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
}

В 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 раз