Skip to content

Edits

An edit is an EditComposition, built with compositionOf { }. It’s data, so you can build it without an instance, keep it in state, and hand the same value to plan, export and the player.

clip appends to the primary track, which is the only track most edits need.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
}

A trim keeps part of a source. The end is always exclusive, whether you pass two durations or a range. Clips play one after another in the order you add them.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/intro.mp4")) {
trim(start = 2.seconds, endExclusive = 6.seconds)
}
clip(MediaSource.of("/path/to/main.mp4")) {
trim(10.seconds..40.seconds)
fadeOut(1.seconds)
}
}

A trimmed export normally re-encodes at least the start of the clip. snapWithin lets the cut move back to the nearest sync sample, up to the duration you give it, so the export can copy the streams instead.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4")) {
trim(start = 12.seconds, endExclusive = 30.seconds)
snapWithin(500.milliseconds)
}
}

The default of zero keeps the cut exactly where the trim put it.

A copied clip ends on the frame the trim’s end lands on, the same one its preview ends on, though a B-frame there can bring a frame or two it decodes from along with it. Its audio runs to the trim’s end. A source whose packets carry no presentation times, like an AVI with B-frames, can’t end a copy early, so a trim end on one re-encodes and copyBlockedBy names SourceUntimed. See Limits.

A clip plays its trim at speed, anywhere from 1/32 to 32. CLIP_SPEED_RANGE holds those ends, and a plan refuses a speed outside them by name. The default is 1.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/wave.mp4")) {
trim(2.seconds..8.seconds)
speed(0.5)
fadeIn(1.seconds)
}
}

The trim stays in source time and everything else is output time. The clip above keeps six seconds of its source and runs twelve on the timeline, so Clip.duration reads twelve seconds, and fades, overlays and visibleDuring are all measured on that output clock. A timeline’s TrimOverlay takes the same speed and writes source time back.

Pitch is always kept, and there’s no option to let it shift. Every backend pins a clip’s audio to its output length. A still has no speed to change, so one set on a still shows up as an inert notice.

A clip’s picture is laid on the output frame grid. Its span covers one output frame for each point half way between two frames that it reaches, and each frame it covers shows the source frame nearest that frame’s source time. Every backend does this at any speed and from a source at any frame rate, within the limits it names, so a 24 fps clip in a 30 fps export shows the nearest of its frames on each output frame, at speed 1 as at any other. That holds however short the clip is:

  • A primary track whose video covers no frame at all is refused with InvalidComposition. At 30 fps that’s 500 ms at 32 or 15 ms at 1, and the message names the run and the frame rate.
  • A clip covering no frame inside a longer run lays no picture. Its neighbours keep their frames, and its audio still plays.
  • A clip covering one frame writes one frame, at 32 as at 1.
  • A trim holding no source frame, like 10 to 30 ms of a 30 fps source, shows the frame nearest its source time, here frame 0, even though that frame sits outside the trim.

Speed has a cost:

  • Sources decode in full, so decoding scales with the speed. A clip at 32 decodes 32 source frames for every frame it writes, and a preview at 32 has to decode that fast to hold its frame rate.
  • On Apple an export renders each sped clip’s audio to temporary disk first, as 16-bit PCM for the clip’s output length, and removes it when the run ends. A long clip at a slow speed needs that much space. See Run for what a caller sees while it renders.

Limits lists what each backend does at the edges.

track adds a layer that plays alongside the primary track. An audio-only track is mixed in with the rest, which is how a music bed works.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4")) {
audio(AudioLevel.Volume(0.5f))
}
track(TrackContent.Audio) {
clip(MediaSource.of("/path/to/music.m4a"))
audio(AudioLevel.Volume(0.3f))
looping()
fadeOut(2.seconds)
}
}

Levels multiply down the scopes, composition then track then clip, and a fade multiplies whatever level it’s on. A track that carries video is refused at plan time unless it’s the primary one.

audio(AudioSpec) sets what happens to the mixed audio. Mute keeps a silent track, Remove writes no audio track at all, AudioOnly drops the video, and Volume scales the mix.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
audio(AudioSpec.Remove)
}