kutu_media_transform 0.2.0
kutu_media_transform: ^0.2.0 copied to clipboard
Device-side crop, trim and re-encode for photos and videos, plus timestamped frame extraction, on Android Media3 and iOS AVFoundation.
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 #
minSdkVersion24 or higher. Flutter's own default is already 24, so there is usually nothing to change. Media3Transformeritself 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.plistkeys. - 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.
