[palette_generator] Add PaletteGenerator.fromByteData (#450)

diff --git a/packages/palette_generator/CHANGELOG.md b/packages/palette_generator/CHANGELOG.md
index 786dcc7..8de0bd6 100644
--- a/packages/palette_generator/CHANGELOG.md
+++ b/packages/palette_generator/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.3.1
+
+* Add PaletteGenerator.fromByteData to allow creating palette from image byte data.
+
 ## 0.3.0
 
 * Migrated to null safety.
diff --git a/packages/palette_generator/lib/palette_generator.dart b/packages/palette_generator/lib/palette_generator.dart
index 176ad51..fac6f87 100644
--- a/packages/palette_generator/lib/palette_generator.dart
+++ b/packages/palette_generator/lib/palette_generator.dart
@@ -10,13 +10,34 @@
 import 'dart:math' as math;
 import 'dart:typed_data';
 import 'dart:ui' as ui;
-import 'dart:ui' show Color;
+import 'dart:ui' show Color, ImageByteFormat;
 
 import 'package:collection/collection.dart'
     show PriorityQueue, HeapPriorityQueue;
 import 'package:flutter/foundation.dart';
 import 'package:flutter/painting.dart';
 
+/// A description of an encoded image.
+///
+/// Used in [PaletteGenerator.fromEncodedImage].
+class EncodedImage {
+  /// Creates a description of an encoded image.
+  const EncodedImage(
+    this.byteData, {
+    required this.width,
+    required this.height,
+  });
+
+  /// Encoded image byte data.
+  final ByteData byteData;
+
+  /// Image width.
+  final int width;
+
+  /// Image height.
+  final int height;
+}
+
 /// A class to extract prominent colors from an image for use as user interface
 /// colors.
 ///
@@ -77,6 +98,72 @@
     _selectSwatches();
   }
 
+  // TODO(gspencergoog): remove `dart:ui` paragragh from [fromByteData] method when https://github.com/flutter/flutter/issues/10647 is resolved
+
+  /// Create a [PaletteGenerator] asynchronously from encoded image [ByteData],
+  /// width, and height. These parameters are packed in [EncodedImage].
+  ///
+  /// The image encoding must be RGBA with 8-bit per channel, this corresponds to
+  /// [ImageByteFormat.rawRgba] or [ImageByteFormat.rawStraightRgba].
+  ///
+  /// In contast with [fromImage] and [fromImageProvider] this method can be used
+  /// in non-root isolates, because it doesn't involve interaction with the
+  /// `dart:ui` library, which is currently not supported, see https://github.com/flutter/flutter/issues/10647.
+  ///
+  /// The [region] specifies the part of the image to inspect for color
+  /// candidates. By default it uses the entire image. Must not be equal to
+  /// [Rect.zero], and must not be larger than the image dimensions.
+  ///
+  /// The [maximumColorCount] sets the maximum number of colors that will be
+  /// returned in the [PaletteGenerator]. The default is 16 colors.
+  ///
+  /// The [filters] specify a lost of [PaletteFilter] instances that can be used
+  /// to include certain colors in the list of colors. The default filter is
+  /// an instance of [AvoidRedBlackWhitePaletteFilter], which stays away from
+  /// whites, blacks, and low-saturation reds.
+  ///
+  /// The [targets] are a list of target color types, specified by creating
+  /// custom [PaletteTarget]s. By default, this is the list of targets in
+  /// [PaletteTarget.baseTargets].
+  static Future<PaletteGenerator> fromByteData(
+    EncodedImage encodedImage, {
+    Rect? region,
+    int maximumColorCount = _defaultCalculateNumberColors,
+    List<PaletteFilter> filters = const <PaletteFilter>[
+      avoidRedBlackWhitePaletteFilter
+    ],
+    List<PaletteTarget> targets = const <PaletteTarget>[],
+  }) async {
+    assert(region == null || region != Rect.zero);
+    assert(
+        region == null ||
+            (region.topLeft.dx >= 0.0 && region.topLeft.dy >= 0.0),
+        'Region $region is outside the image ${encodedImage.width}x${encodedImage.height}');
+    assert(
+        region == null ||
+            (region.bottomRight.dx <= encodedImage.width &&
+                region.bottomRight.dy <= encodedImage.height),
+        'Region $region is outside the image ${encodedImage.width}x${encodedImage.height}');
+    assert(
+      encodedImage.byteData.lengthInBytes ~/ 4 ==
+          encodedImage.width * encodedImage.height,
+      "Image byte data doesn't match the image size, or has invalid encoding. "
+      'The encoding must be RGBA with 8 bit per channel.',
+    );
+
+    final _ColorCutQuantizer quantizer = _ColorCutQuantizer(
+      encodedImage,
+      maxColors: maximumColorCount,
+      filters: filters,
+      region: region,
+    );
+    final List<PaletteColor> colors = await quantizer.quantizedColors;
+    return PaletteGenerator.fromColors(
+      colors,
+      targets: targets,
+    );
+  }
+
   /// Create a [PaletteGenerator] from an [dart:ui.Image] asynchronously.
   ///
   /// The [region] specifies the part of the image to inspect for color
@@ -103,26 +190,21 @@
     ],
     List<PaletteTarget> targets = const <PaletteTarget>[],
   }) async {
-    assert(region == null || region != Rect.zero);
-    assert(
-        region == null ||
-            (region.topLeft.dx >= 0.0 && region.topLeft.dy >= 0.0),
-        'Region $region is outside the image ${image.width}x${image.height}');
-    assert(
-        region == null ||
-            (region.bottomRight.dx <= image.width &&
-                region.bottomRight.dy <= image.height),
-        'Region $region is outside the image ${image.width}x${image.height}');
+    final ByteData? imageData =
+        await image.toByteData(format: ui.ImageByteFormat.rawRgba);
+    if (imageData == null) {
+      throw 'Failed to encode the image.';
+    }
 
-    final _ColorCutQuantizer quantizer = _ColorCutQuantizer(
-      image,
-      maxColors: maximumColorCount,
-      filters: filters,
+    return PaletteGenerator.fromByteData(
+      EncodedImage(
+        imageData,
+        width: image.width,
+        height: image.height,
+      ),
       region: region,
-    );
-    final List<PaletteColor> colors = await quantizer.quantizedColors;
-    return PaletteGenerator.fromColors(
-      colors,
+      maximumColorCount: maximumColorCount,
+      filters: filters,
       targets: targets,
     );
   }
@@ -1082,28 +1164,26 @@
 
 class _ColorCutQuantizer {
   _ColorCutQuantizer(
-    this.image, {
+    this.encodedImage, {
     this.maxColors = PaletteGenerator._defaultCalculateNumberColors,
     this.region,
     this.filters = const <PaletteFilter>[avoidRedBlackWhitePaletteFilter],
-  })  : assert(region == null || region != Rect.zero),
-        _paletteColors = <PaletteColor>[];
+  }) : assert(region == null || region != Rect.zero);
 
-  FutureOr<List<PaletteColor>> get quantizedColors async {
-    if (_paletteColors.isNotEmpty) {
-      return _paletteColors;
-    } else {
-      return _quantizeColors(image);
-    }
-  }
-
-  final ui.Image image;
-  final List<PaletteColor> _paletteColors;
-
+  final EncodedImage encodedImage;
   final int maxColors;
   final Rect? region;
   final List<PaletteFilter> filters;
 
+  Completer<List<PaletteColor>>? _paletteColorsCompleter;
+  FutureOr<List<PaletteColor>> get quantizedColors async {
+    if (_paletteColorsCompleter == null) {
+      _paletteColorsCompleter = Completer<List<PaletteColor>>();
+      _paletteColorsCompleter!.complete(_quantizeColors());
+    }
+    return _paletteColorsCompleter!.future;
+  }
+
   Iterable<Color> _getImagePixels(ByteData pixels, int width, int height,
       {Rect? region}) sync* {
     final int rowStride = width * 4;
@@ -1152,7 +1232,7 @@
     return false;
   }
 
-  Future<List<PaletteColor>> _quantizeColors(ui.Image image) async {
+  List<PaletteColor> _quantizeColors() {
     const int quantizeWordWidth = 5;
     const int quantizeChannelWidth = 8;
     const int quantizeShift = quantizeChannelWidth - quantizeWordWidth;
@@ -1168,13 +1248,13 @@
       );
     }
 
-    final ByteData? imageData =
-        await image.toByteData(format: ui.ImageByteFormat.rawRgba);
-    if (imageData == null) {
-      throw 'Failed to encode the image.';
-    }
-    final Iterable<Color> pixels =
-        _getImagePixels(imageData, image.width, image.height, region: region);
+    final List<PaletteColor> paletteColors = <PaletteColor>[];
+    final Iterable<Color> pixels = _getImagePixels(
+      encodedImage.byteData,
+      encodedImage.width,
+      encodedImage.height,
+      region: region,
+    );
     final _ColorHistogram hist = _ColorHistogram();
     Color? currentColor;
     _ColorCount? currentColorCount;
@@ -1205,16 +1285,16 @@
     if (hist.length <= maxColors) {
       // The image has fewer colors than the maximum requested, so just return
       // the colors.
-      _paletteColors.clear();
+      paletteColors.clear();
       for (final Color color in hist.keys) {
-        _paletteColors.add(PaletteColor(color, hist[color]!.value));
+        paletteColors.add(PaletteColor(color, hist[color]!.value));
       }
     } else {
       // We need use quantization to reduce the number of colors
-      _paletteColors.clear();
-      _paletteColors.addAll(_quantizePixels(maxColors, hist));
+      paletteColors.clear();
+      paletteColors.addAll(_quantizePixels(maxColors, hist));
     }
-    return _paletteColors;
+    return paletteColors;
   }
 
   List<PaletteColor> _quantizePixels(
diff --git a/packages/palette_generator/pubspec.yaml b/packages/palette_generator/pubspec.yaml
index 6c18d10..5923cb9 100644
--- a/packages/palette_generator/pubspec.yaml
+++ b/packages/palette_generator/pubspec.yaml
@@ -2,7 +2,7 @@
 description: Flutter package for generating palette colors from a source image.
 repository: https://github.com/flutter/packages/tree/master/packages/palette_generator
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+palette_generator%22
-version: 0.3.0
+version: 0.3.1
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
diff --git a/packages/palette_generator/test/palette_generator_test.dart b/packages/palette_generator/test/palette_generator_test.dart
index e2de939..024b6c3 100644
--- a/packages/palette_generator/test/palette_generator_test.dart
+++ b/packages/palette_generator/test/palette_generator_test.dart
@@ -39,7 +39,7 @@
   }
 }
 
-Future<ImageProvider> loadImage(String name) async {
+Future<FakeImageProvider> loadImage(String name) async {
   File imagePath = File(path.joinAll(<String>['assets', name]));
   if (path.split(Directory.current.absolute.path).last != 'test') {
     imagePath = File(path.join('test', imagePath.path));
@@ -59,7 +59,8 @@
     'dominant',
     'landscape'
   ];
-  final Map<String, ImageProvider> testImages = <String, ImageProvider>{};
+  final Map<String, FakeImageProvider> testImages =
+      <String, FakeImageProvider>{};
   for (final String name in imageNames) {
     testImages[name] = await loadImage('$name.png');
   }
@@ -71,7 +72,34 @@
     tester.pumpWidget(const Placeholder());
   });
 
-  test('PaletteGenerator works on 1-pixel wide blue image', () async {
+  test(
+      "PaletteGenerator.fromByteData throws when the size doesn't match the byte data size",
+      () {
+    expect(
+      () async {
+        final ByteData? data =
+            await testImages['tall_blue']!._image.toByteData();
+        await PaletteGenerator.fromByteData(
+          EncodedImage(
+            data!,
+            width: 1,
+            height: 1,
+          ),
+        );
+      },
+      throwsAssertionError,
+    );
+  });
+
+  test('PaletteGenerator.fromImage works', () async {
+    final PaletteGenerator palette =
+        await PaletteGenerator.fromImage(testImages['tall_blue']!._image);
+    expect(palette.paletteColors.length, equals(1));
+    expect(palette.paletteColors[0].color,
+        within<Color>(distance: 8, from: const Color(0xff0000ff)));
+  });
+
+  test('PaletteGenerator works on 1-pixel tall blue image', () async {
     final PaletteGenerator palette =
         await PaletteGenerator.fromImageProvider(testImages['tall_blue']!);
     expect(palette.paletteColors.length, equals(1));
@@ -79,7 +107,7 @@
         within<Color>(distance: 8, from: const Color(0xff0000ff)));
   });
 
-  test('PaletteGenerator works on 1-pixel high red image', () async {
+  test('PaletteGenerator works on 1-pixel wide red image', () async {
     final PaletteGenerator palette =
         await PaletteGenerator.fromImageProvider(testImages['wide_red']!);
     expect(palette.paletteColors.length, equals(1));
@@ -190,7 +218,7 @@
     expect(palette.paletteColors.length, equals(15));
   });
 
-  test('PaletteGenerator Filters work', () async {
+  test('PaletteGenerator filters work', () async {
     final ImageProvider imageProvider = testImages['landscape']!;
     // First, test that supplying the default filter is the same as not supplying one.
     List<PaletteFilter> filters = <PaletteFilter>[
@@ -358,6 +386,26 @@
       lastPalette = palette;
     }
   });
+
+  // TODO(gspencergoog): rewrite to use fromImageProvider when https://github.com/flutter/flutter/issues/10647 is resolved,
+  // since fromImageProvider calls fromImage which calls fromByteData
+
+  test('PaletteGenerator.fromByteData works in non-root isolate', () async {
+    final ui.Image image = testImages['tall_blue']!._image;
+    final ByteData? data = await image.toByteData();
+    final PaletteGenerator palette =
+        await compute<EncodedImage, PaletteGenerator>(
+      _computeFromByteData,
+      EncodedImage(data!, width: image.width, height: image.height),
+    );
+    expect(palette.paletteColors.length, equals(1));
+    expect(palette.paletteColors[0].color,
+        within<Color>(distance: 8, from: const Color(0xff0000ff)));
+  });
+}
+
+Future<PaletteGenerator> _computeFromByteData(EncodedImage encodedImage) async {
+  return PaletteGenerator.fromByteData(encodedImage);
 }
 
 bool onlyBluePaletteFilter(HSLColor hslColor) {