kutu_media_transform

Device-side crop, trim and re-encode for photos and videos, plus timestamped frame extraction. Android runs on Media3 Transformer; iOS runs on AVMutableVideoComposition and AVAssetExportSession. No FFmpeg, no bundled codecs, no extra megabytes in your app.

Why this exists

No Flutter package crops video on device. The pickers that come closest either skip the crop step or hand you crop parameters and tell you to process the file yourself. The FFmpeg bindings that could do it are archived, single-maintainer forks, or effectively GPLv3 — and they add 8–40 MB of native libraries per ABI to every consumer, whether or not that consumer ever crops a video.

The platform APIs cost zero bytes and are patent-clean, because the device manufacturer already paid for the hardware encoder. This package is that path, behind one Dart seam.

Install

dependencies:
  kutu_media_transform: ^0.2.0

Quick start

Everything goes through MediaTransform. The production implementation is KutuMediaTransform, which is a const constructor over the platform channel.

import 'dart:io';
import 'dart:typed_data';

import 'package:kutu_media_transform/kutu_media_transform.dart';

const MediaTransform transform = KutuMediaTransform();

Crop and re-encode a photo

exportImage decodes at the target size, applies the crop, bakes EXIF orientation exactly once, and writes a new file. A full-resolution bitmap is never allocated.

Future<File> centreSquare(String sourcePath) {
  return transform.exportImage(
    sourcePath,
    const CropRect(left: 0.125, top: 0, right: 0.875, bottom: 1),
    const ImageEncodeSettings(quality: 85, maxLongEdge: 2048),
  );
}

Crop and trim a video

Future<File> reframe(String sourcePath, ValueNotifier<double> progress) {
  return transform.exportVideo(
    sourcePath,
    const CropRect(left: 0, top: 0.1, right: 1, bottom: 0.9),
    const DurationRange(
      start: Duration(seconds: 2),
      end: Duration(seconds: 12),
    ),
    const VideoEncodeSettings(codec: VideoCodec.hevc, maxLongEdge: 1080),
    onProgress: (double value) => progress.value = value,
    cancelToken: TransformCancelToken(),
  );
}

Extract frames at timestamps

Platform gallery thumbnails have no timestamp parameter — you get the poster frame and nothing else. This is how you build a trim filmstrip or a cover-frame picker.

Future<List<Uint8List>> filmstrip(String sourcePath) {
  return transform.extractFrames(
    sourcePath,
    const <Duration>[
      Duration.zero,
      Duration(seconds: 5),
      Duration(seconds: 10),
    ],
    const ThumbSize.square(120),
  );
}

On iOS this is one batched AVAssetImageGenerator call. On Android there is no batch API, so it is one MediaMetadataRetriever call per timestamp at low resolution — expect Android to be slower for long lists, and cache the result.

Probe a video

final VideoInfo info = await transform.probeVideo(sourcePath);

codedWidth and codedHeight are pre-rotation. displayWidth and displayHeight are post-rotation — those are the numbers a CropRect is relative to, and the ones to show a user. Reading the coded size and cropping against it is how portrait videos end up cropped sideways.

The geometry contract

CropRect is normalized 0..1, origin top-left, y-down, and it is the only geometry type in the public API.

const CropRect topHalf = CropRect(left: 0, top: 0, right: 1, bottom: 0.5);

Media3 wants normalized device coordinates (-1..1, y-up); AVFoundation wants points in a y-up render space. Both conversions happen at the platform edge and nowhere else, so a caller never has to think about which platform it is on. CropRect.full() is the identity rect, isFull tests for it, and isValid rejects inverted or out-of-range rectangles.

Two rounding rules the platform side enforces, because they are silent visual bugs otherwise: H.264 and HEVC require even output width and height, and rounding must be identical in your preview and in the export or you get a persistent one-pixel drift that is very visible on a 1:1 crop.

Encode settings

ImageEncodeSettings

field type default meaning
quality int 92 1..100, JPEG only
maxLongEdge int? null cap on the longer output edge; applied at decode, so it also caps peak memory
format ImageOutputFormat jpeg jpeg or png

VideoEncodeSettings

field type default meaning
codec VideoCodec h264 h264 or hevc
maxLongEdge int? 1080 resolution cap; null keeps the source size
bitrate int? null bits per second; null uses the platform default for the resolution
hdrMode HdrMode toneMapToSdr see below
keepAudio bool true drop the audio track when false

HDR

The default is HdrMode.toneMapToSdr, and that default is deliberate. iPhone 12 and later record Dolby Vision Profile 8.4 with an HLG base in 10-bit HEVC, and that hybrid is the single largest source of "why is my video washed out": a naive transcode drops the metadata and a naive player then misreads the transfer function. Android tone-maps with HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL.

HdrMode.keepHdr passes HDR through. Use it only if every downstream consumer of the file — your server ladder, your player, your thumbnailer — is known to handle it.

Progress and cancellation

exportVideo reports 0.0..1.0 through onProgress and accepts a TransformCancelToken. Cancellation is cooperative: calling cancel() asks the platform encoder to stop and the future completes with a TransformException whose failure is TransformFailure.cancelled. There is no partial output file to clean up.

Note that any crop forces a full decode → GPU → encode pass. Transmuxing only survives unchanged geometry, so a cropped export always pays for a re-encode, with all its 10-bit, 60 fps and 4K memory consequences. Run exports one at a time.

Errors

Every failure is a TransformException carrying a TransformFailure:

try {
  await transform.exportVideo(path, crop, trim, settings);
} on TransformException catch (error) {
  final String message = switch (error.failure) {
    TransformFailure.sourceUnreadable => 'The file could not be opened.',
    TransformFailure.unsupportedFormat => 'That format is not supported here.',
    TransformFailure.encoderFailed => 'The device encoder gave up.',
    TransformFailure.cancelled => 'Cancelled.',
    TransformFailure.outOfMemory => 'Not enough memory for this source.',
    TransformFailure.unknown => error.message,
  };
}

Platform setup

This package needs no runtime permissions. It operates on file paths you already have; obtaining those paths is the caller's problem, and if they came from the gallery then the gallery permissions are the caller's to declare.

Android

  • minSdkVersion 24 or higher. Flutter's own default is already 24, so there is usually nothing to change. Media3 Transformer itself requires 21.
  • Java 17 / jvmTarget = 17, which is Flutter's current default.
  • No <uses-permission> entries.

iOS

  • Deployment target 13.0 or higher.
  • No Info.plist keys.
  • Both CocoaPods and Swift Package Manager work: the plugin ships a podspec and a Package.swift.

Not supported

Web, Windows and Linux. There is no software encoder to fall back to and adding one would mean bundling the codecs this package exists to avoid.

What this package does not do

  • Rotate, straighten, flip or apply any affine transform other than an axis-aligned crop.
  • Filters, adjustments, overlays or text.
  • Merge, split or reorder clips within one asset.
  • Choose your files. Pair it with kutu_asset_picker for that.

Testing without a device

import 'package:kutu_media_transform/testing.dart';

FakeMediaTransform implements MediaTransform and lets you unit-test everything above the seam with no platform channel and no simulator.

Licence

MIT. See LICENSE.

Libraries

kutu_media_transform
Native device-side crop, trim and re-encode for photos and videos.
testing
Test doubles for package:kutu_media_transform.