
Перед вами вторая статья из моего цикла про воспроизведение и редактирование медиа с помощью AVFoundation. В предыдущей статье я рассказал, что такое простой ассет. Если вы её пропустили, советую заглянуть туда прямо сейчас — без этого некоторые моменты новой статьи могут остаться непонятными.
Сегодня я разберу тему сложных ассетов. А ещё познакомлю вас с идеями, которые могут лежать в основе любого видеопроигрывателя или видеоредактора.
Композиции
Композиция ассетов — это тоже ассет, его и называют композицией. Например, мультимедийный объект может комбинировать несколько аудио- и видеоассетов.
Композиция — это объект, который представляет собой сочетание нескольких ассетов (видео, аудио и других типов медиа) в одном мультимедийном контейнере. Она управляет временными диапазонами, треками и их синхронизацией.

Рассмотрим пример композиции ассетов — iMovie позволяет выстроить несколько видео подряд:

Попробуйте записать на iPhone 5-секундное видео в режиме SLO-MO. После записи проверьте его длительность — она окажется 15 секунд. Как iPhone растянул видео- и аудиопоток на дополнительные 10 секунд? А в режиме TIME-LAPSE всё наоборот. Секрет в том, что iPhone под капотом создаёт композицию продолжительностью 15 секунд, где видеопоток растянут на всю её длительность (на самом деле всё чуть сложнее, но об этом позже).
Продвинутые видеоредакторы, такие как CupCat, позволяют создавать целые коллажи из видео. Думаю, вам уже понятно, что это ещё один пример композиции ассетов:

При работе с композицией важно, чтобы и вы, и система знали ответы на такие вопросы для каждого ассета:
В каком временном диапазоне композиции его размещать?
С какой части начинать воспроизведение?
С какой скоростью его проигрывать?
В каких координатах отрисовывать видео в конкретный момент времени?
Какой уровень громкости выставлять для аудио в определённый момент?
Но вопросы возникают не только к отдельным ассетам, но и к самой композиции. Например:
Какой размер видео у композиции?
Какая громкость у всей композиции?
Без ответов на эти и другие вопросы композицию не собрать. По ходу статьи я покажу, как идейно решать каждый из них.
Для работы с композициями в AVFoundation есть классы AVComposition: AVAsset и AVMutableComposition: AVComposition.
Начинающие iOS-разработчики, не знакомые с Objective-C, могут запутаться в этих двух классах. Но всё проще, чем кажется: первый (AVComposition
) — только для чтения, второй (AVMutableComposition
) — можно редактировать. Раньше Apple часто использовала такой подход.
Отмечу, что AVComposition
идёт в альтернативной ветке наследования от AVAsset
, а не от AVURLAsset
. Это намекает, что его нельзя просто взять и прочитать по ссылке. Именно поэтому не стоит завязывать всё в приложении на AVURLAsset
, если вы работаете с файлами из галереи iPhone.
Треки композиций
Композиция полностью расширяет и усложняет возможности простых ассетов. В предыдущей статье я показал, что многое скрывается в их треках. Для композиций это тоже справедливо.
Для работы с треками композиции в AVFoundation есть классы AVCompositionTrack: AVAssetTrack и AVMutableCompositionTrack: AVCompositionTrack.
Помните SLO-MO видео из начала статьи? Пересмотрите его ещё раз. Оно не проигрывается с одинаковой скоростью: начало и конец — на обычной, а середина — на замедленной. Во второй половине прошлой статьи я упомянул свойство segments
. Догадались? Это разные сегменты одного видеотрека.
Сегменты в композиции обрабатываются через AVCompositionTrackSegment: AVAssetTrackSegment. От родителя этот класс отличается свойством var sourceTrackID: CMPersistentTrackID { get }
. Вы можете задаться вопросом: почему указатель на трек есть тут, а у родителя его нет? Ответ дам чуть позже.
Давайте разберём сегменты видеотрека одного из моих SLO-MO видео наглядно:
let composition: AVComposition
let videoTrack = try await composition.loadTracks(withMediaType: .video).first!
try await videoTrack.load(.segments)
- 0 : <AVCompositionTrackSegment: ... timeRange [0.000,+2.475] from trackID 1 of asset ссылка sourceTimeRange [0.000,+2.475]>
- 1 : <AVCompositionTrackSegment: ... timeRange [2.475,+0.015] from trackID 1 of asset ссылка sourceTimeRange [2.475,+0.013]>
- 2 : <AVCompositionTrackSegment: ... timeRange [2.490,+0.045] from trackID 1 of asset ссылка sourceTimeRange [2.488,+0.033]>
- 3 : <AVCompositionTrackSegment: ... timeRange [2.535,+0.083] from trackID 1 of asset ссылка sourceTimeRange [2.522,+0.048]>
- 4 : <AVCompositionTrackSegment: ... timeRange [2.618,+0.130] from trackID 1 of asset ссылка sourceTimeRange [2.570,+0.055]>
- 5 : <AVCompositionTrackSegment: ... timeRange [2.748,+0.182] from trackID 1 of asset ссылка sourceTimeRange [2.625,+0.050]>
- 6 : <AVCompositionTrackSegment: ... timeRange [2.930,+84.748] from trackID 1 of asset ссылка sourceTimeRange [2.675,+10.593]>
- 7 : <AVCompositionTrackSegment: ... timeRange [87.678,+0.107] from trackID 1 of asset ссылка sourceTimeRange [13.268,+0.028]>
- 8 : <AVCompositionTrackSegment: ... timeRange [87.785,+0.075] from trackID 1 of asset ссылка sourceTimeRange [13.297,+0.032]>
- 9 : <AVCompositionTrackSegment: ... timeRange [87.860,+0.048] from trackID 1 of asset ссылка sourceTimeRange [13.328,+0.028]>
- 10 : <AVCompositionTrackSegment: ... timeRange [87.908,+0.027] from trackID 1 of asset ссылка sourceTimeRange [13.357,+0.020]>
- 11 : <AVCompositionTrackSegment: ... timeRange [87.935,+0.008] from trackID 1 of asset ссылка sourceTimeRange [13.377,+0.008]>
- 12 : <AVCompositionTrackSegment: ... timeRange [87.943,+0.078] from trackID 1 of asset ссылка sourceTimeRange [13.385,+0.080]>
- 13 : <AVCompositionTrackSegment: ... timeRange [88.022,+2.375] from trackID 1 of asset ссылка sourceTimeRange [13.465,+2.375]>
Следите за руками. Первый и последний сегменты имеют одинаковую длительность source
и target
(первый: +2.475
, последний: +2.375
) — они проигрываются с нормальной скоростью. А вот сегменты со второго по предпоследний отличаются по длительности source
и target
— видео растягивается дольше, чем есть на самом деле. Особенно это заметно в середине. Так и рождается эффект замедления, который вы видите.
Давайте разберём сегмент №8 детально:
- 8 : <AVCompositionTrackSegment: ... timeRange [87.785,+0.075] from trackID 1 of asset ссылка sourceTimeRange [13.297,+0.032]>
try await videoTrack.load(.segments)[8]
<
AVCompositionTrackSegment: 0x30219a3e0
timeRange [87.785,+0.075]
from trackID 1 of asset file:///var/mobile/Media/DCIM/100APPLE/IMG_0283.MOV
sourceTimeRange [13.297,+0.032]
>
Что тут происходит? Из видеотрека с ID 1 файла file:///var/mobile/Media/DCIM/100APPLE/IMG_0283.MOV
вырезали кусок длительностью 0.032 секунды, начиная с 13.297 секунды. В композиции этот кусок разместили на 87.785 секунде с длительностью 0.075 секунды. Скорость сегмента считаем так:
let segment: AVCompositionTrackSegment
let speed = segment.timeMapping.source.duration.seconds / segment.timeMapping.target.duration.seconds
speed // 0.032 / 0.075 = 43%
Пока рано говорить о сборке композиции вручную, но начало положено. Как работает TIME-LAPSE, вы теперь можете разобрать сами.
Если вы решите проверить сегменты своих SLO-MO или TIME-LAPSE видео, столкнётесь с трудностями при их открытии в коде. Как я упомянул выше, экспортировать видео из галереи телефона в файл и открывать по ссылке — не лучшая идея для работы с композицией. Как правильно открывать композицию из галереи, разберём в следующих статьях.
Кстати, вы могли заметить, что в списке сегментов я скрыл ссылки. Это для краткости — они всё равно одинаковые. Но ссылки пригодятся, когда вы начнёте соединять разные видео в одной композиции.
Давайте взглянем на сегменты видеотрека композиции из двух видео, записанных на iPhone, которую я собрал за кадром:
let composition: AVComposition
let videoTrack = try await composition.loadTracks(withMediaType: .video).first!
try await videoTrack.load(.segments)
- 0 : <
AVCompositionTrackSegment: 0x3039bb9c0
timeRange [0.000,+5.780]
from trackID 1
of asset file:///private/var/mobile/Containers/Data/Application/B3B974E0-1059-4358-8FE3-D51DDA490A2C/tmp/VideoEditor/Video/4920F4D2-AE9D-41C5-A4FB-9593F7F06EE1.MOV
sourceTimeRange [0.000,+5.780]
>
- 1 : <
AVCompositionTrackSegment: 0x3039bbbf0
timeRange [5.780,+5.000]
from trackID 1
of asset file:///private/var/mobile/Containers/Data/Application/B3B974E0-1059-4358-8FE3-D51DDA490A2C/tmp/VideoEditor/GeneratedVideo/0FDE836A-E944-45B6-81EE-8936B7B4E9F0.mov
sourceTimeRange [0.000,+5.000]
>
Что видно? Во-первых, разные файлы — это действительно композиция из двух видео. Во-вторых, оба файла проигрываются с естественной скоростью: timeRange
совпадает с sourceTimeRange
. В-третьих, первое видео стартует с 0 секунды композиции, второе — с 5.780.
Теперь понятно, почему свойство trackID
есть только у AVCompositionTrackSegment
, а не у родителя AVAssetTrackSegment
. Когда вы работаете с композициями, важно знать, из какого ассета (asset file
), из какого трека (trackID
) и из какого диапазона (sourceTimeRange
) взят кусок для размещения в композиции.
Эта композиция — просто пример. Я не советую вставлять несколько сегментов из разных ассетов в один трек композиции. В простых случаях это сработает, но в сложных — с разными размерами, частотой кадров или контейнерами — возникнут проблемы, которые будет сложно преодолеть. О них я расскажу позже. Лучше выделяйте для каждого ассета отдельный трек композиции.
Теперь вы понимаете, как устроены треки композиции, и можете создавать их самостоятельно. Давайте заглянем в код и начнём с обзора AVMutableCompositionTrack
.
Для добавления или удаления трека в композицию используйте методы AVMutableComposition
:
// добавление пустого трека с заданным ID
func addMutableTrack(
withMediaType mediaType: AVMediaType,
preferredTrackID: CMPersistentTrackID
) -> AVMutableCompositionTrack?
// удаление трека по ID
func removeTrack(_ track: AVCompositionTrack)
Используйте kCMPersistentTrackID_Invalid
для preferredTrackID
— система сама выберет подходящий ID. Пример: composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
.
Есть две стратегии изменения треков. Первая — вырезать нужные части из трека ассета в трек композиции через методы AVMutableCompositionTrack
:
// добавление части трека ассета в трек композиции
func insertTimeRange(
_ timeRange: CMTimeRange,
of track: AVAssetTrack,
at startTime: CMTime
) throws
// добавление пустой части в трек композиции
func insertEmptyTimeRange(_ timeRange: CMTimeRange)
Я не раз сталкивался с ошибкой -11800 AVErrorUnknown
при использовании этой стратегии, хотя аргументы были корректны. Поэтому не рекомендую вам применять её.
Кстати, между сегментами трека не должно быть пустого пространства, иначе выплывет ошибка -11824 AVErrorCompositionTrackSegmentsNotContiguous
. Там, где заканчивается один сегмент, должен начинаться другой или трек должен завершаться. Для этого у каждого сегмента есть свойство isEmpty
, а у трека — метод для добавления пустых участков. Дополню, что первый сегмент всегда стартует с нуля (targetTimeRange.start
= 0).
Если вы видите коды ошибок в логах, например -11820
, и не знаете, что они значат, вот лайфхак: нажмите cmd+shift+o
, введите AVError
, откройте файл по пути AVFoundation > AVError
. Там все коды с расшифровками:
typedef NS_ERROR_ENUM(AVFoundationErrorDomain, AVError) {
AVErrorUnknown = -11800,
AVErrorOutOfMemory = -11801,
AVErrorSessionNotRunning = -11803,
AVErrorDeviceAlreadyUsedByAnotherSession = -11804,
AVErrorNoDataCaptured = -11805,
AVErrorSessionConfigurationChanged = -11806,
...
}
Вторая стратегия — создать сегменты и добавить их массивом в трек:
// инициализатор сегмента трека композиции по части ассета
AVCompositionTrackSegment(
// URL ассета
url URL: URL,
// ID трека ассета для вырезания
trackID: CMPersistentTrackID,
// временной диапазон вырезания из ассета
sourceTimeRange: CMTimeRange,
// временной диапазон вставки в композицию
targetTimeRange: CMTimeRange
)
// инициализатор пустого сегмента трека композиции
AVCompositionTrackSegment(timeRange: CMTimeRange)
Для этого у AVCompositionTrack
есть методы:
// проверка сегментов на корректность
func validateSegments(_ trackSegments: [AVCompositionTrackSegment]) throws
// переменная для установки сегментов массивом
var segments: [AVCompositionTrackSegment]!
Бонус от меня — метод AVCompositionTrack
, который упрощает изменение скорости трека или его части:
// изменение длительности указанного отрезка
func scaleTimeRange(_ timeRange: CMTimeRange, toDuration duration: CMTime)
Теперь у вас достаточно знаний, чтобы создать композицию с двумя последовательными видео. Они могут выглядеть неидеально (например, часть ассетов окажется за пределами проигрывания, показывая чёрный экран), но это пока не важно. Аудио опустим для простоты — его обработка идентична видео.
Давайте соберём композицию двумя способами, используя обе стратегии.
Подготовим ассеты и их треки:
let asset1 = AVURLAsset(url: asset1LocalUrl)
let asset2 = AVURLAsset(url: asset2LocalUrl)
let asset1VideoTrack = try await asset1.loadTracks(withMediaType: .video).first!
let asset2VideoTrack = try await asset2.loadTracks(withMediaType: .video).first!
Создадим композицию и видеотреки:
let composition = AVMutableComposition()
// видеотрек для первого ассета
let compositionVideoTrack1 = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
)!
// видеотрек для второго ассета
let compositionVideoTrack2 = ... // аналогично
Проверим, что треки добавлены:
try await composition.load(.tracks)
// оба видеотрека на месте — успех
<AVMutableComposition: 0x60000029c980 tracks = (
"<AVMutableCompositionTrack: ... trackID = 1, mediaType = vide, ...",
"<AVMutableCompositionTrack: ... trackID = 2, mediaType = vide, ..."
)>
I have an asset, I have a composition... Ooo, compositionasset. Стратегия первая:
// добавляем в первый видеотрек всё из первого ассета
let asset1VideoTrackTimeRange = try await asset1VideoTrack.load(.timeRange)
try compositionVideoTrack1.insertTimeRange(
asset1VideoTrackTimeRange,
of: asset1VideoTrack,
at: .zero
)
// добавляем во второй видеотрек отступ на длину первого ассета
compositionVideoTrack2.insertEmptyTimeRange(asset1VideoTrackTimeRange)
// добавляем во второй видеотрек всё из второго ассета после первого
try compositionVideoTrack2.insertTimeRange(
try await asset2VideoTrack.load(.timeRange),
of: asset2VideoTrack,
at: asset1VideoTrackTimeRange.end
)
Проверим результат:
for track in try await composition.load(.tracks) {
try await track.load(.segments)
}
▿ 0 : Optional<Array<AVCompositionTrackSegment>>
▿ some : 1 element
- 0 : <
AVCompositionTrackSegment: ...
timeRange [0.000,+10.008]
from trackID 1
of asset ссылка_1
sourceTimeRange [0.000,+10.008]
>
▿ 1 : Optional<Array<AVCompositionTrackSegment>>
▿ some : 2 elements
- 0 : <AVCompositionTrackSegment: ... timeRange [0.000,+10.008] is empty>
- 1 : <
AVCompositionTrackSegment: ...
timeRange [10.008,+5.000]
from trackID 2
of asset ссылка_2
sourceTimeRange [0.000,+5.000]
>
Два ассета расположились на двух треках друг за другом — то что нужно. Успех!
I have an asset, I have a composition... Ooo, compositionasset. Стратегия вторая:
// сегмент для первого видеотрека
let asset1VideoTrackTimeRange = try! await asset1VideoTrack.load(.timeRange)
var compositionVideoTrack1Segments: [AVCompositionTrackSegment] = []
compositionVideoTrack1Segments.append(
AVCompositionTrackSegment(
url: asset1LocalUrl,
trackID: asset1VideoTrack.trackID,
sourceTimeRange: asset1VideoTrackTimeRange,
targetTimeRange: asset1VideoTrackTimeRange
)
)
// валидируем и добавляем
try! compositionVideoTrack1.validateSegments(compositionVideoTrack1Segments)
compositionVideoTrack1.segments = compositionVideoTrack1Segments
// сегменты для второго видеотрека
let asset2VideoTrackTimeRange = try! await asset2VideoTrack.load(.timeRange)
var compositionVideoTrack2Segments: [AVCompositionTrackSegment] = []
compositionVideoTrack2Segments.append(
contentsOf: [
// отступ по длине первого ассета
AVCompositionTrackSegment(timeRange: asset1VideoTrackTimeRange),
// видео из второго ассета
AVCompositionTrackSegment(
url: asset2LocalUrl,
trackID: asset2VideoTrack.trackID,
sourceTimeRange: asset2VideoTrackTimeRange,
targetTimeRange: CMTimeRange(
start: asset1VideoTrackTimeRange.duration,
duration: asset2VideoTrackTimeRange.duration
)
)
]
)
// валидируем и добавляем
try! compositionVideoTrack2.validateSegments(compositionVideoTrack2Segments)
compositionVideoTrack2.segments = compositionVideoTrack2Segments
Проверка покажет тот же результат, что и в первой стратегии. Обе работают одинаково в простом случае.
В прошлой статье я советовал не делать расширение на AVAsset
для получения размера в пикселях через первый видеотрек. Теперь ясно почему: AVComposition
— тоже AVAsset
, но у неё может быть много видеотреков, и первый не всегда определяет размер композиции.
Вы можете попробовать проиграть получившуюся композицию через AVPlayer
, но результат пока не впечатлит. Лучше сделайте это в конце статьи.
Мы подошли к ключевому моменту. Теперь вам должно быть понятно, как реализовать самую главную функцию любого видеоредактора: расположение ассетов в композиции. Повторю: для каждого ассета создавайте отдельные треки (аудио и/или видео), переносите контент с треков ассета на треки композиции.
Из этой функции можно вывести массу возможностей для пользователей. Например:
Расположение подряд (аудио и/или видео). Размещайте ассеты в композиции с учётом отступов.
Наложения (аудио на видео, видео на видео). Пересекайте
targetTimeRange
треков, чтобы они проигрывались одновременно.Обрезка. Редактируйте
sourceTimeRange
сегментов, сохраняя соответствие сtargetTimeRange
, чтобы не менять скорость.Ускорение и замедление. Настраивайте
sourceTimeRange
иtargetTimeRange
так, чтобыsourceTimeRange.duration / targetTimeRange.duration = x
, гдеx
— коэффициент ускорения (>1) или замедления (<1).
Уверен, вы придумаете ещё кучу идей.
Отмечу, что алгоритмы для этих функций будут сложными и объёмными, а также зависеть от требований вашего приложения. Поэтому я не буду углубляться в них в этом теоретическом цикле — моя цель заложить концептуальные идеи. Но этих идей хватит, чтобы начать практиковаться и реализовывать всё самим.
Для примера покажу «внутренности» сложной композиции, которую я собрал за кадром:

Сначала опишу её словами (время в секундах):
Видео A: фрагмент 0.0–0.6 из файла длительностью 6.0, размещён на 0.0–6.0 композиции (скорость 1x).
Видео B: фрагмент 0.0–0.5 из файла длительностью 5.0, размещён на 0.5–5.0 композиции (скорость 1x).
Видео C: фрагмент 69.0–77.0 из файла длительностью 300.0, размещён на 6.0–10.0 композиции (скорость 2x).
Музыка: фрагмент 0.0–6.0 из файла длительностью 190.0, размещена на 2.0–8.0 композиции (скорость 1x).
Бонус: между 5.0 и 6.0 в коллаже — последний кадр видео B.
Треки такой композиции:
let composition: AVMutableComposition
try await composition.load(.tracks)
// Музыка
"<AVMutableCompositionTrack: ... trackID = 1, mediaType = soun, ...>",
// Видео A
"<AVMutableCompositionTrack: ... trackID = 2, mediaType = vide, ...>",
"<AVMutableCompositionTrack: ... trackID = 3, mediaType = soun, ...>",
// Видео B
"<AVMutableCompositionTrack: ... trackID = 4, mediaType = vide, ...>",
"<AVMutableCompositionTrack: ... trackID = 5, mediaType = soun, ...>",
// Видео C
"<AVMutableCompositionTrack: ... trackID = 6, mediaType = vide, ...>",
"<AVMutableCompositionTrack: ... trackID = 7, mediaType = soun, ...>"
Сегменты треков:
for track in try await composition.load(.tracks) {
try await track.load(.segment)
}
// MARK: - Музыка
▿ 0 : 2 elements // <- аудиотрек
// сдвиг на 2.0
- 0 : <AVCompositionTrackSegment: ... timeRange [0.0,+2.0] is empty>
// сама музыка
- 1 : <AVCompositionTrackSegment: ...
timeRange [2.0,+6.0] // <- куда вставили
from trackID 1 // <- аудиотрек исходного файла
of asset музыка.mp3
sourceTimeRange [0.000,+6.0]> // <- откуда вырезали
// MARK: - Видео A
▿ 1 : 1 element // <- видеотрек
- 0 : <AVCompositionTrackSegment: ...
timeRange [0.0,+6.0] // <- куда вставили
from trackID 1 // <- видеотрек исходного файла
of asset видео_a.mov
sourceTimeRange [0.0,+6.0]> // <- откуда вырезали
▿ 2 : 1 element // <- аудиотрек
- 0 : <AVCompositionTrackSegment: ...
timeRange [0.0,+6.0] // <- куда вставили
from trackID 2 // <- аудиотрек исходного файла
of asset видео_a.mov
sourceTimeRange [0.0,+6.0]> // <- откуда вырезали
// MARK: - Видео B
▿ 3 : 2 elements // <- видеотрек
// контент
- 0 : <AVCompositionTrackSegment: ...
timeRange [0.0,+5.0] // <- куда вставили
from trackID 1 // <- видеотрек исходного файла
of asset видео_b.mov
sourceTimeRange [0.0,+5.0]> // <- откуда вырезали
// последний кадр, растянут на 1.0
- 1 : <AVCompositionTrackSegment: ...
timeRange [5.0,+1.0] // <- куда вставили
from trackID 1 // <- видеотрек исходного файла
of asset видео_b.mov
sourceTimeRange [5.0,+0.0]> // <- откуда вырезали
▿ 4 : 1 element // <- аудиотрек
// контент
- 0 : <AVCompositionTrackSegment: ...
timeRange [0.000,+5.0] // <- куда вставили
from trackID 2 // <- аудиотрек исходного файла
of asset видео_b.mov
sourceTimeRange [0.0,+5.0]> // <- откуда вырезали
// MARK: - Видео C
// Скорость 2x, обратите внимание на sourceTimeRange и timeRange
▿ 5 : 2 elements // <- видеотрек
// сдвиг на 6.0
- 0 : <AVCompositionTrackSegment: ... timeRange [0.0,+6.0] is empty>
// контент
- 1 : <AVCompositionTrackSegment: ...
timeRange [6.0,+4.0] // <- куда вставили
from trackID 1 // <- видеотрек исходного файла
of asset видео_c.mov
sourceTimeRange [69.0,+8.0]> // <- откуда вырезали
▿ 6 : 2 elements // <- аудиотрек
// сдвиг на 6.0
- 0 : <AVCompositionTrackSegment: ... timeRange [0.0,+6.0] is empty>
// контент
- 1 : <AVCompositionTrackSegment: ...
timeRange [6.0,+4.0] // <- куда вставили
from trackID 2 // <- аудиотрек исходного файла
of asset видео_c.mov
sourceTimeRange [69.0,+8.0]> // <- откуда вырезали
Комментарии избыточно подробные, чтобы вы сразу поняли, как устроена такая композиция. Зная методы создания треков и переноса контента, вы сможете собрать что-то похожее сами. А я двигаюсь дальше.
Выше я показал, как расположить контент на треках. Но вы не увидели ни намёка на свойства, определяющие координаты отрисовки или громкость. Это хороший вопрос — треки и сегменты сами по себе не знают, где и как их отрисовывать или какую громкость выставлять. За это отвечают другие инструменты, которые я разберу ниже. Они не часть композиции, но используются вместе с ней для проигрывания и экспорта. Эти процессы мы рассмотрим в следующих статьях.
Видеокомпозиция
В AVFoundation почти все данные для отрисовки видеопотоков задаются через видеокомпозицию.
Видеокомпозиция — это объект, который управляет визуальными аспектами композиции: расположением и масштабом видеотреков, их синхронизацией и отрисовкой на экране.
Для работы с видеокомпозицией AVFoundation предлагает два класса: AVVideoComposition и AVMutableVideoComposition: AVVideoComposition.
Я разберу только ключевые и часто используемые свойства AVVideoComposition
:
// размер композиции в пикселях
var renderSize: CGSize { get }
// длина кадра
var frameDuration: CMTime { get }
// инструкции — рабочие лошадки видеокомпозиции
var instructions: [any AVVideoCompositionInstructionProtocol] { get }
С var renderSize: CGSize { get }
всё просто — его нужно указать, чтобы система знала, какой размер вы хотите. Допустим, вам нужна композиция 1000x1000, хотя внутри — горизонтальное 4K-видео. Задаёте CGSize(width: 1000, height: 1000)
, и готово. Как видео разных размеров уместятся в таком квадрате — вопрос отдельный, разберём его позже.
А вот var frameDuration: CMTime { get }
посложнее. Это свойство определяет, как часто создаются новые кадры при воспроизведении композиции.
Представьте видео с частотой 30 FPS (кадров в секунду). Это значит, что кадр обновляется каждые 1/30
секунды. Если вы задаёте frameDuration
как CMTime(value: 1, timescale: 24)
, кадры будут сменяться каждые 1/24
секунды — получится 24 FPS. Установите CMTime(value: 1, timescale: 60)
— будет 60 FPS, и видео станет плавнее.
Это свойство управляет плавностью и влияет на отображение композиции.
Кстати, как и renderSize
, frameDuration
подстраивает все видеотреки под себя. Но делает это более предсказуемо.
Как вычислять frameDuration
? Всё зависит от вашей программы. Одной нужно всегда 30 FPS, другой — 60 FPS, третьей — среднее значение между всеми треками. По моему опыту, лучше взять максимальный FPS среди треков и ограничить его допустимым значением для вашего приложения.
Теперь переходим к var instructions: [any AVVideoCompositionInstructionProtocol] { get }
— инструкциям видеокомпозиции. Тут всё непросто. Пристегнитесь, взлетаем!
Инструкции видеокомпозиции — это объекты, которые решают, как отображать видеотреки на определённом участке композиции. Они задают трансформации (масштаб, поворот, позицию) и визуальные эффекты для каждого кадра.
Для работы с инструкциями есть классы AVVideoCompositionInstruction и AVMutableVideoCompositionInstruction: AVVideoCompositionInstruction. Второй в сложных случаях требует создания наследника — об этом поговорим в следующих статьях. Пока ограничимся простыми примерами.
Вот ключевые свойства AVVideoCompositionInstruction
:
// временной диапазон, где действует инструкция
var timeRange: CMTimeRange { get }
// инструкции размещения видеотреков на этом диапазоне
var layerInstructions: [AVVideoCompositionLayerInstruction] { get }
// цвет фона на этом диапазоне
var backgroundColor: CGColor? { get }
Что это значит? Инструкция описывает состояние видеокомпозиции на участке timeRange
. Поле layerInstructions
указывает, какие видеопотоки отрисовывать и с какими трансформациями. Если потоки не заполняют весь renderSize
, пустое место заливается backgroundColor
.
Пример: хотите совместить X видео с A по B секунду? Задайте timeRange
от A до B и для каждого из X видео пропишите свою layerInstructions
, чтобы расположить их в кадре.
А что такое инструкция размещения? Она отвечает на вопросы для каждого видеотрека на заданном timeRange
:
Какая прозрачность и где?
Какая трансформация и где?
Какая обрезка и где?
Для этого есть классы AVVideoCompositionLayerInstruction и AVMutableVideoCompositionLayerInstruction: AVVideoCompositionLayerInstruction.
Инициализация AVMutableVideoCompositionLayerInstruction
возможна только так, ведь инструкция привязана к конкретному треку:
convenience init(assetTrack track: AVAssetTrack)
Методы для ответов на вопросы:
Какая прозрачность и где?
func setOpacityRamp(fromStartOpacity startOpacity: Float, toEndOpacity endOpacity: Float, timeRange: CMTimeRange)
func setOpacity(_ opacity: Float, at time: CMTime)
Какая трансформация и где?
func setTransformRamp(fromStart startTransform: CGAffineTransform, toEnd endTransform: CGAffineTransform, timeRange: CMTimeRange)
func setTransform(_ transform: CGAffineTransform, at time: CMTime)
Какая обрезка и где?
func setCropRectangleRamp(fromStartCropRectangle startCropRectangle: CGRect, toEndCropRectangle endCropRectangle: CGRect, timeRange: CMTimeRange)
func setCropRectangle(_ cropRectangle: CGRect, at time: CMTime)
Подведём итог. Вся композиция разбивается на непересекающиеся временные промежутки. Для каждого нужен объект видеоинструкции. А для каждой видеоинструкции — набор инструкций размещения видеотреков.
Возможно, вам придётся перечитать это пару раз, чтобы всё уложилось в голове. Как только поймёте — выдыхайте, пора практиковаться. Помните предыдущую задачу с двумя последовательными видео? Теперь вы понимаете, почему результат воспроизведения был так себе — мы не настроили видеокомпозицию. Давайте исправим.
Создаём видеокомпозицию:
let videoComposition = AVMutableVideoComposition()
Задаём базовые параметры:
videoComposition.renderSize = CGSize(width: 2000, height: 2000)
videoComposition.frameDuration = CMTime(value: 1, timescale: 60)
Настраиваем инструкции:
// MARK: - Инструкция для первого трека
let compositionVideoTrack1Instruction = AVMutableVideoCompositionInstruction()
// Базовые параметры
compositionVideoTrack1Instruction.backgroundColor = UIColor.red.cgColor
compositionVideoTrack1Instruction.timeRange = asset1VideoTrackTimeRange
// Инструкция размещения
let compositionVideoTrack1LayerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack1)
compositionVideoTrack1LayerInstruction.setTransform(
try await asset1VideoTrack_foot.load(.preferredTransform),
at: .zero
)
compositionVideoTrack1Instruction.layerInstructions = [compositionVideoTrack1LayerInstruction]
// MARK: - Инструкция для второго трека
let compositionVideoTrack2Instruction = ... // аналогично
videoComposition.instructions = [
compositionVideoTrack1Instruction,
compositionVideoTrack2Instruction
]
Так мы создали видеокомпозицию для двух видео. Но кое-где схитрили. Обратите внимание на setTransform
— я передал туда try await asset1VideoTrack.load(.preferredTransform)
. Вы помните из прошлой статьи, что preferredTransform
выправляет исходный видеопоток ассета, но не располагает его в композиции. В таком виде выправленный ассет просто прижмётся верхним левым углом к началу координат. Давайте разберём это наглядно.
Без setTransform
пиксели видеопотока лягут в композицию как есть:

С setTransform
и preferredTransform
пиксели выправятся, но не растянутся на композицию:

Видите, трансформация в setTransform
должна быть сложнее. Она должна не только выправлять пиксели через preferredTransform
, но и располагать их в композиции. Как это сделать, зависит от логики вашего приложения — вам решать.
Если хотите добавить управление видео через жесты (увеличение по пинчу, сдвиг по свайпу), просто добавьте эти изменения к результирующей трансформации.
Кстати, в примере со сложной композицией (видео A, B, C и музыка) видеоинструкции можно задать так: одна с 0.0 по 6.0 (для A и B), вторая с 6.0 по 10.0 (для C).
Мы подошли к ещё одному важному моменту цикла. Теперь вы не только знаете, как собрать медиапотоки в композицию и расположить в них контент из ассетов, но и понимаете, как запрограммировать их геометрию.
С видео разобрались. А что с аудио? Узнаем прямо сейчас.
Аудиомикс
Тут всё будет легко и приятно. Работая с аудио в композиции, вам нужно ответить всего на два вопроса:
Какая громкость у конкретного аудиотрека и на каком участке?
Какая общая громкость всей композиции?
Посмотрите на пример от Apple. Громкость аудио в их композиции меняается: сначала высокая и постоянная, потом линейно падает, затем держится низкой, после линейно растёт и в конце снова становится высокой и постоянной.

Для этого и придумали аудиомикс.
Аудиомикс — это объект, который управляет звуковыми аспектами композиции.
В AVFoundation для работы с аудиомиксом есть классы AVAudioMix и AVMutableAudioMix: AVAudioMix. Они до смешного просты — у них всего одно свойство:
var inputParameters: [AVAudioMixInputParameters]
Входные параметры — это настройки каждого аудиотрека. Сколько треков, столько и параметров в аудиомиксе.
Для работы с ними есть классы AVAudioMixInputParameters и AVMutableAudioMixInputParameters: AVAudioMixInputParameters.
Инициализатор AVAudioMixInputParameters
намекает, что каждому треку нужен свой параметр:
convenience init(track: AVAssetTrack?)
Настраивать звук трека проще простого с методами AVMutableAudioMixInputParameters
. Уровень громкости в AVFoundation — это число от 0 до 1:
func setVolume(_ volume: Float, at time: CMTime)
func setVolumeRamp(
fromStartVolume startVolume: Float,
toEndVolume endVolume: Float,
timeRange: CMTimeRange
)
Тут две опции. Первая — задать громкость volume
с момента time
и дальше через setVolume
. Вторая — настроить плавное изменение от startVolume
до endVolume
на участке timeRange
, а затем удерживать endVolume
.
Давайте применим это к нашей знакомой композиции с музыкой и видео A, B, C. Хотим громкость как на картинке:

Создаём аудиомикс:
let audioMix = AVMutableAudioMix()
Настраиваем входной параметр для музыки:
let musicCompositionTrack: AVCompositionTrack
let musicInput = AVMutableAudioMixInputParameters(track: musicCompositionTrack)
musicInput.setVolume(1, at: .zero)
musicInput.trackID = musicCompositionTrack.trackID
Теперь для видео:
let videoACompositionTrack: AVCompositionTrack
let videoAInput = AVMutableAudioMixInputParameters(track: videoACompositionTrack)
videoAInput.setVolumeRamp(
fromStartVolume: 1,
toEndVolume: 0,
timeRange: .init(start: 0, end: 2)
)
videoAInput.trackID = videoACompositionTrack.trackID
let videoBInput = // аналогично видео A
let videoCCompositionTrack: AVCompositionTrack
let videoCInput = AVMutableAudioMixInputParameters(track: videoCCompositionTrack)
videoCInput.setVolume(0, at: .zero)
videoCInput.setVolumeRamp(
fromStartVolume: 0,
toEndVolume: 1,
timeRange: .init(start: 8, end: 10)
)
videoCInput.trackID = videoCCompositionTrack.trackID
Добавляем параметры в аудиомикс:
audioMix.inputParameters = [videoAInput, videoBInput, videoCInput, musicInput]
Готово! Теперь вы знаете, как управлять звуком в композиции. Это ещё одна важная часть нашего цикла, и, как я обещал, довольно простая.
Заключение
В этой статье мы проделали большой путь, чтобы разобраться в устройстве композиции ассетов и понять ключевые идеи, на которых строится любой видеоредактор или видеопроигрыватель. Вот они:
Композиция ассетов через
AVMutableComposition
.Настройка видеотреков через
AVMutableVideoComposition
.Настройка аудиотреков через
AVAudioMix
.
Раньше я просил вас повременить с экспериментами по созданию и воспроизведению композиций. Теперь говорю — пора начинать! Для старта дам простой шаблон, с которым вы можете играться прямо в SwiftUI Canvas (превью). Напомню: про воспроизведение подробно поговорим в следующих статьях, так что пока не зацикливайтесь на этом.
import SwiftUI
import AVKit
import AVFoundation
struct ContentView: View {
@State var player: AVPlayer?
var body: some View {
VStack { VideoPlayer(player: player) }
.onAppear(perform: { Task { do { try await start() } catch {} } })
}
private func start() async throws {
// готовим урлы
let asset1LocalUrl = Bundle.main.url(forResource: "video_1", withExtension: "mov")!
let asset2LocalUrl = Bundle.main.url(forResource: "video_2", withExtension: "mov")!
// готовим ассеты
let asset1 = AVURLAsset(url: asset1LocalUrl)
let asset2 = AVURLAsset(url: asset2LocalUrl)
// готовим композицию
let composition = AVMutableComposition()
// готовим видеокомпозицию
let videoComposition = AVVideoComposition()
// готовим аудиомикс
let audioMix = AVMutableAudioMix()
// показываем кино
let playerItem = AVPlayerItem(asset: composition)
playerItem.videoComposition = videoComposition
playerItem.audioMix = audioMix
player = AVPlayer(playerItem: playerItem)
}
}
#Preview {
ContentView()
}
На этом пока всё. Впереди нас ждут импорт, экспорт и проигрывание ассетов, а ещё собственный алгоритм отрисовки видеокадров — будет интересно!