Skip to content

Export

An export is two steps. plan decides what this device will do with an edit, without decoding or writing anything, and export runs that plan.

ExportSpec describes the output, separately from the edit, so one composition can be exported several ways. A plain ExportSpec() keeps the source’s size and frame rate and lets filmstrip pick the codecs. Anything left null keeps what the composition already has.

val spec =
ExportSpec(
heightPx = 720,
bitrate = Bitrate.mbps(2),
videoCodec = VideoCodec.H264,
frameRate = 30,
hdr = HdrMode.ToneMapToSdr,
)

By default filmstrip falls back when it has to and reports every change. Set strict = true when a codec, a size or a bitrate can’t move, and the plan refuses instead.

Each preset is a plain ExportSpec you opt into, so you can copy one and change what you need.

Preset Height Video Audio HDR
Upload up to 1080p H.264 at 4 Mbps Auto Tone mapped
Share up to 720p H.264 at 2 Mbps AAC Tone mapped
Archive source HEVC at High quality AAC Kept
Original source the first clip’s own codec Auto Auto

Original asks for VideoCodec.MatchSource, which picks a codec the way Auto does when filmstrip has no name for the source’s codec. When the device has no encoder for the source’s codec it falls back and reports a CodecFallback. It never blocks a stream copy, so an edit that could be copied still is.

heightPx is a maximum. A height below the composition’s own scales it down, and one at or above it keeps the composition’s size, so a 720p clip exported with Upload stays 720p. A composition with no video clip, framed by a photo, takes the height as asked. To scale a video up to an exact size, use the Scale effect.

val composition =
compositionOf {
clip(MediaSource.of("/path/to/720p.mp4"))
effects { scale(1080) }
}

Leave bitrate unset and filmstrip picks one from a quality tier, the same number on every platform. The tiers are VeryLow, Low, Medium, High and VeryHigh. The rate scales with the frame’s size and with the codec, so HEVC gets less than H.264 for the same look, and with the frame rate above 30 fps. A transcode that names neither runs at High.

val draft = ExportSpec(heightPx = 480, quality = VideoQuality.Low)

Set bitrate or quality, not both, since a spec that names both is refused. Either one rules out a stream copy. When an encoder publishes a ceiling, a rate above it is held there and reported as a BitrateClamped adjustment. The plan’s output.bitrate is the rate the encoder is handed.

plan returns a Verdict. It’s cheap, has no side effects and never throws.

val plan: ExportPlan? =
when (val verdict = filmstrip.plan(composition, ExportSpec.Upload)) {
is Verdict.Capable -> {
verdict.plan
}
is Verdict.Degraded -> {
verdict.adjustments.forEach { println("Changed: ${it.message}") }
verdict.plan
}
is Verdict.Incapable -> {
verdict.reasons.forEach { println("Can't export: ${it.message}") }
verdict.withoutUnsupported?.plan
}
}
  • Capable runs the edit exactly as written.
  • Degraded runs it with changes, each listed as an Adjustment. Show the ones that change how the output looks, like a tone map or a dropped effect, before the user commits.
  • Incapable can’t run it and lists why. When the only problem is effects this device can’t render, withoutUnsupported carries a plan with those effects left out.

Some parts of an edit render nothing, like a brightness of one or an overlay with zero opacity. filmstrip leaves them out of the export and lists each on the plan, so you can tell the user why a control had no effect.

plan.inertNotices.forEach { notice ->
println("Left out ${notice.subject}: ${notice.message}")
}

export returns a flow of ExportStatus. It ends in Success or Failure, both of which are ExportStatus.Finished.

filmstrip.export(plan, MediaSink.temporary()).collect { status ->
when (status) {
ExportStatus.Started -> println("Started")
is ExportStatus.Adjusted -> println("Adjusted: ${status.adjustments}")
is ExportStatus.Progress -> println("${(status.fraction * 100).toInt()}%")
is ExportStatus.Success -> println("Wrote ${status.output}, ${status.info.duration}")
is ExportStatus.Failure -> println("Failed: ${status.error.message}")
}
}
  • Started arrives when the work actually begins. Exports run one at a time across the process, so a second one waits for the first. On Apple an export with a clip at a speed begins by rendering that clip’s audio, so Started comes before the render and the first Progress only once frames are being written.
  • Adjusted only comes from the export(composition, spec, sink) overload, which plans for you. Running a plan you already have doesn’t repeat it.
  • Progress.fraction only ever goes up.
  • Success carries where the file went, what it turned out to be, and every adjustment again.

There’s no cancelled status. Cancel the collecting scope and the export stops.

An SD source displays the same colour after an export on Apple as it did before. A source tagged with SMPTE C or EBU primaries or a BT.601 matrix, or one VideoToolbox reads that way when it carries no tags, is written as true BT.709 on macOS and iOS alike. Preview, frames read and thumbnails draw the same colour as the export. It can sit beside an HDR clip in a tone mapped export on any iOS version a tone map runs on.

Moving SMPTE C or EBU primaries on Apple runs a Core Image Metal kernel, which takes an Apple6 GPU on iOS or on macOS 12. A device that can’t compile one refuses a clip on those primaries, tagged or read that way, the same way it refuses a tone map.

The browser backend moves SMPTE C and EBU primaries the same way, in an export and in a preview.

The FFmpeg backend keeps an SD source’s codes and tags when every clip shares one SD colour, which displays the same colour, and its preview and thumbnails draw that colour too. Beside anything else, a BT.709 clip or a tone mapped HDR clip or another SD colour, it writes the file as true BT.709, moving each SD clip’s primaries. An effect that draws on the picture, a grade or an overlay or Vhs, does the same on its own, so it reads the clip’s colour as BT.709 on every backend. A trim, a rotate, a crop or a scale keeps the codes and tags, and so does an effect that renders nothing. A build with no colorspace filter refuses a clip on SD primaries. A source with no tags stays that way with its codes unmoved, so a player that guesses its colour by frame size, the way VideoToolbox does, can guess differently once it is scaled up.

The media3 backend writes SMPTE C and EBU primaries as true BT.709 too, in an export and in a preview. A source with no tags exports as Android draws it.

On every backend an SD source beside an HDR one has no grade to keep, so HdrMode.KeepHdr refuses the two and HdrMode.Auto tone maps them to SDR together, where the SD source’s primaries move as above.

Film from filmstrip-effects-filters makes the frame look like it was shot on film. Register the renderer with filters() on the builder. grain and halation run from 0f to 1f, on a clip, a track or the composition, and either at 0f skips its part. Halation glows warm around the brightest parts of the picture, and grain lands over the result, heaviest in the midtones.

Grain comes from the frame’s composition time and its place on a fixed raster, and the glow’s width is a fraction of the frame width, so a seek or a smaller preview shows the same frame. It runs over the fill and under any overlay at the same scope. On a clip with a contain crop, its bars stay clear and the fill shows through unchanged. A clip overlay under a composition Film is part of the clip’s frame by then, and takes the look with it. An HDR source tone maps to SDR first, reported as a concession under HdrMode.Auto, and HdrMode.KeepHdr refuses it.

Every backend draws the same frame, within a limit of its own. Apple draws the glow with Core Image’s Gaussian, within a couple of codes of the shared blur. ffmpeg draws it with gblur and rounds through RGB and back, which lands the whole look within 2.5 codes of an 8 bit limited range source, and within about 5 of a full range or SD one. Apple refuses a Film on a device that can’t compile its Core Image Metal kernel, the same way it refuses a tone map.

ffmpeg reads the grain from planes filmstrip draws on the CPU and hands over a loopback socket, once per cell of the raster where a cell spans whole pixels and once per pixel on any other frame. An export with H.264 on an Intel i5-12400 ran at about 17.5 frames a second at 1080p and 7.9 at 4K, against 26.93 and 34.90 with no effect. Where a JVM can’t bind a loopback listener or the build has no tcp protocol, a geq draws the same samples at about 2 frames a second, and the diagnostics say so. See Limits.

Every failure is a FilmstripError value. Nothing in filmstrip’s public API throws one, so branch on the type instead of catching.

val reason =
when (val error = failure.error) {
is FilmstripError.BackendMissing -> "Add ${error.artifact} to your dependencies"
is FilmstripError.ToolchainMissing -> "Install ffmpeg: ${error.message}"
is FilmstripError.InsufficientStorage -> "Free up some space and try again"
is FilmstripError.TimedOut -> "The ${error.operation} took longer than ${error.deadline}, try again"
else -> error.message
}

TimedOut means a probe or platform read was stopped at its deadline. It says nothing about the source, so the same call may succeed later.