Optimize images with Dart

Jeff Huleatt

portrait of Jeff Huleatt

I’m learning Dart, and thought I’d try to build something that comes up often in web development: image optimization. I built a little image optimization function that resizes an image into a thumbnail-sized version.

Trying Dart

Overall, Dart felt a lot like a more polished version of JavaScript (which makes sense, given its history). That’s great for me since JavaScript is the main language I use.

The built-in tooling is excellent. Having dart create, dart format, dart run, dart test out of the box is way better than having to DIY a toolchain with TypeScript, Prettier, Vitest, and the like.

The package ecosystem on pub.dev is strong but, since Dart can run both in client apps (Flutter) and on the server, you have to be careful whether a package supports your environment. For example, flutter_image_compress seems to be the standard image optimization library, but I wanted to make sure my code could run on the server, so I couldn’t use it.

I thought the differentiation between Exceptions and Errors was pretty interesting. Exceptions are expected problems, like an invalid file type, that should be caught and conveyed to the end user, while Errors are unexpected errors that should crash the program. Pretty neat!

Image manipulation

I chose the image library to handle image resizing, since it can run on the server.

My original plan was to convert all images to WebP. WebP encoding was recently added to image, but isn’t available in a released version yet. I used dependency_overrides in pubspec.yaml to use the commit that added WebP support, but unfortunately found a bug that meant WebP was a dead-end for now.

Instead, I decided to just resize images in their original format. That was a lot easier.

Deployment

For now, I’m just calling this as a local command-line tool, but with a bit of modification to use Cloud Storage instead of the local filesystem, this could be deployed to Cloud Run. Give Flutter on Cloud Run: Full Stack Dart Architecture a read if you want to learn more about how to do that.

Or, maybe this could be deployed to Cloud Functions for Firebase sometime in the future? 👀

The finished code

import 'dart:io';

import 'package:image/image.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart' as p;

/// The result of a [resizeImage] operation.
class ResizeResult {
  /// The path to the resized output file.
  final String outputPath;

  /// The width of the resized image in pixels.
  final int width;

  /// The height of the resized image in pixels.
  final int height;

  const ResizeResult({
    required this.outputPath,
    required this.width,
    required this.height,
  });
}

/// Resizes an image to the given [width] (default 720), maintaining
/// the original aspect ratio, and saves it in its original format.
///
/// The output file will be saved alongside
/// the input file with `_resized` appended (e.g. `photo.png` becomes
/// `photo_resized.png`).
///
/// Throws an [ArgumentError] if the input file does not exist or is not an image.
/// Throws a [FormatException] if the image cannot be decoded.
/// Throws a [StateError] if the image fails to encode and save.
Future<ResizeResult> resizeImage(String inputPath, {int width = 720}) async {
  // Check that the file exists and that it is an image
  final inputFile = File(inputPath);
  if (!await inputFile.exists()) {
    throw ArgumentError('Input file does not exist: $inputPath');
  }
  final mimeType = lookupMimeType(inputPath);
  if (mimeType == null || !mimeType.startsWith('image/')) {
    throw ArgumentError(
      'Input file is not an image. Detected MIME type: $mimeType',
    );
  }

  // Decode the image
  final imageBytes = await inputFile.readAsBytes();
  final image = decodeImage(imageBytes);
  if (image == null) {
    throw FormatException(
      'Failed to decode the image. Ensure it is a valid image file.',
    );
  }

  // Resize the image, maintaining aspect ratio
  final resizedImage = copyResize(image, width: width);

  // Save the resized image
  final extension = p.extension(inputPath);
  final outputPath = p.join(
    p.dirname(inputPath),
    '${p.basenameWithoutExtension(inputPath)}_resized$extension',
  );
  final success = await encodeImageFile(outputPath, resizedImage);
  if (!success) {
    throw StateError('Failed to encode and save the resized image.');
  }

  return ResizeResult(
    outputPath: outputPath,
    width: resizedImage.width,
    height: resizedImage.height,
  );
}