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.
The spec
Section titled “The spec”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.
Presets
Section titled “Presets”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.
Height
Section titled “Height”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) } }Quality
Section titled “Quality”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 } }Capableruns the edit exactly as written.Degradedruns it with changes, each listed as anAdjustment. Show the ones that change how the output looks, like a tone map or a dropped effect, before the user commits.Incapablecan’t run it and lists why. When the only problem is effects this device can’t render,withoutUnsupportedcarries a plan with those effects left out.
Inert notices
Section titled “Inert notices”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}") }}Startedarrives 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, soStartedcomes before the render and the firstProgressonly once frames are being written.Adjustedonly comes from theexport(composition, spec, sink)overload, which plans for you. Running a plan you already have doesn’t repeat it.Progress.fractiononly ever goes up.Successcarries 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.
Colour
Section titled “Colour”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.
Failures
Section titled “Failures”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.