Skip to content

Stills

Stills go both ways in filmstrip. A photo can sit on the timeline as a clip, and a frame of an edit can be written out as an image.

image adds a still held for a duration. It’s on a composition and on a track, and is marked @ExperimentalFilmstripApi.

val composition =
compositionOf {
image(ImageSource.of("/path/to/title.jpg"), duration = 3.seconds)
clip(MediaSource.of("/path/to/clip.mp4"))
}

A still has no frame rate of its own. The output uses spec.frameRate, then the rate of the first clip on the primary track that decodes video, then 30, so an all-photo edit with no rate comes out at 30.

Where the primary track holds no clip that decodes video and the spec names no height, the output is held to 3840x2160, since a photo’s bounds are a sensor’s and not a frame an encoder is built for. Set both when the edit is all photos.

val spec = ExportSpec(heightPx = 1080, frameRate = 30)

Android and Apple put a still on the timeline. The JVM and browser backends decode their picture from a video track, so they refuse a still by name wherever it appears, an audio track included.

still renders a frame of a composition, encodes it and writes it to a sink. It decodes at the size it writes, so it doesn’t render at full resolution and shrink.

val result =
filmstrip.still(
composition = composition,
at = 3.seconds,
to = MediaSink.of("/path/to/poster.jpg"),
spec = StillSpec(format = StillFormat.Jpeg, quality = 85, heightPx = 720),
)
when (result) {
is StillResult.Success -> println("Wrote ${result.size} to ${result.output}")
is StillResult.Failure -> println(result.error.message)
}

StillSpec is all optional.

  • format is Jpeg by default. Png and Jpeg write on every target.
  • quality runs from 0 to 100, 90 by default. A value outside that is clamped, and Png ignores it.
  • heightPx is the height to encode at, and the width follows from the aspect. Null keeps the frame’s own height. Zero or less fails with InvalidComposition.

For one source with no edit, pass the source directly. MediaSink.Temporary picks a file for you, and StillResult.Success.output says where it went.

filmstrip.still(MediaSource.of("/path/to/clip.mp4"), at = 1.seconds, to = MediaSink.Temporary)

To keep the bytes, encode a frame you already rendered with encodeStill. The frame stays open, so closing it is still your job.

val encoded = frame.image.encodeStill(StillSpec(format = StillFormat.Png))