Skip to content

Overlays

Overlays are effects, so they go in an effects { } block. They run after geometry has settled, which is what makes their measurements fractions of the final frame. An overlay on a clip is measured against the clip’s frame, and one on the composition against the output frame.

imageOverlay draws a picture into a corner.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
effects {
imageOverlay(
image = ImageSource.of("/path/to/logo.png"),
corner = Corner.BottomEnd,
scale = 0.15f,
opacity = 0.8f,
)
}
}
  • scale is the picture’s width as a fraction of the frame’s width, a fifth by default. The height follows the picture’s own aspect.
  • margin is the inset from the corner as a fraction of the frame’s shorter side, 4% by default.
  • opacity runs from 0f to 1f.

The image comes from an ImageSource, which is a path, a URI or encoded bytes. The picture is drawn in the color its file describes, and an untagged picture is read as sRGB. SDR output clips a tagged picture’s colors to BT.709, and an HDR grade keeps them inside BT.2020. When a platform’s decoder can’t land the picture that way, the effect resolves as degraded or unsupported and says why.

An export reads a path or URI picture as it is on disk when the export starts, so a file changed after planning draws as changed and a deleted one is refused by name. A browser fetches a URI again for each export, and refuses it by name if the fetch takes longer than 60 seconds. Bytes are held by value and never change.

textOverlay burns text into the frame. A preview and its export break lines on the same words.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
effects {
textOverlay(
text = "Day one",
at = 2.seconds..6.seconds,
style =
TextStyle(
fontSize = 0.05f,
weight = FontWeight.Bold,
backgroundColor = 0x80000000.toInt(),
),
anchor = Anchor.TopCenter,
)
}
}

Every size in a TextStyle is a fraction of the frame. fontSize is the cap height against the frame’s height, 6% by default, and maxWidth is where lines wrap, 90% of the width by default. Colors are packed ARGB, as 0xAARRGGBB, and backgroundColor draws a plate behind the text.

Text has no margin of its own. The anchor names the same point on the text and on the frame, so Anchor.BottomCenter, the default, puts the bottom edge of the text on the bottom edge of the frame.

Without a window an overlay draws for its whole scope. Both calls take at, a range of composition time where the end is exclusive, or a visibleDuring TimeRange if you already have one.

Every overlay takes an animation, asked once per output frame how to draw. fade ramps the opacity.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
effects {
textOverlay(
text = "Day one",
at = 2.seconds..6.seconds,
animation = fade(inDuration = 500.milliseconds, outDuration = 1.seconds),
)
}
}

fade takes either side alone, and a side you leave out has no ramp. So fade(inDuration = 500.milliseconds) only fades in. Each ramp is linear, and a range shorter than the two ramps takes the lower of the two opacities.

slideIn and slideOut move the overlay between its authored position and an offset. + runs two animations together. Opacities and scales multiply, and offsets add.

val appear = fade(inDuration = 300.milliseconds)
val arrive = appear + slideIn(OverlayOffset(x = -0.2f, y = 0f), 300.milliseconds)
val composition =
compositionOf {
clip(MediaSource.of("/path/to/clip.mp4"))
effects {
imageOverlay(
image = ImageSource.of("/path/to/logo.png"),
corner = Corner.TopStart,
animation = arrive,
)
}
}

An offset is a fraction of the frame, with positive x towards the end edge and positive y down. An offset can carry the overlay off the frame, and the part that leaves isn’t drawn.

Anything else is a lambda, since OverlayAnimation is a fun interface. It reads an OverlayTime, where elapsed and remaining count across the range the overlay is drawn over and composition is the frame’s place on the timeline. It answers an OverlayFrame with an opacity, an offset and a scale.

val pulse =
OverlayAnimation { time ->
val grown = 1f + (time.elapsed.inWholeMilliseconds % 1000) / 10000f
OverlayFrame(scale = grown)
}

The scale grows the overlay away from the point it’s anchored by. On text it resamples the raster, and never lays the glyphs out again.

A range with no end reads remaining as infinite, so the out side of a fade holds full opacity and slideOut holds the authored position.

Animations are compared, since two overlays that draw differently can’t share a cached frame. The built-in helpers compare by value. A lambda compares by identity, so hold one instance and reuse it, or every rebuilt composition misses the thumbnail cache.

An overlay at zero opacity, empty text or a window outside its scope draws nothing. The plan leaves it out and lists it on its inert notices.