diff --git a/config.lock.yaml b/config.lock.yaml index 9890a8e..2fbfb58 100644 --- a/config.lock.yaml +++ b/config.lock.yaml @@ -2,6 +2,7 @@ adaptive_number: ea9178fdd4d82ac45cf0ec966ac870dae661124f dots_indicator: 508f5883ac79bdbc10254092de3f28f571d261cd ed25519_edwards: 7353ba759ea9f4646cbf481c2ef949625c8ce4cf exif: bf170d5639f0b6fcb0947060cf8bd7b623df9069 +flutter_blurhash: c5cdec4986432e835bb91f5ce00564534450cdc7 flutter_markdown_plus: dc1185c933fbf9dba559ef6c91586ff1503be3ee flutter_sharing_intent: aa1672f547d6579585fa27df0b28ffa2a2544aaa hand_signature: 1beedb164d093643365b0832277c377353c7464f diff --git a/config.yaml b/config.yaml index 58aeb21..65b6c1c 100644 --- a/config.yaml +++ b/config.yaml @@ -66,4 +66,8 @@ exif: git: https://github.com/bigflood/dartexif.git dependencies: sprintf: - git: https://github.com/Naddiseo/dart-sprintf.git \ No newline at end of file + git: https://github.com/Naddiseo/dart-sprintf.git + + +flutter_blurhash: + git: https://github.com/fluttercommunity/flutter_blurhash.git \ No newline at end of file diff --git a/flutter_blurhash/LICENSE b/flutter_blurhash/LICENSE new file mode 100755 index 0000000..8d9bded --- /dev/null +++ b/flutter_blurhash/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Robert Felker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/flutter_blurhash/lib/flutter_blurhash.dart b/flutter_blurhash/lib/flutter_blurhash.dart new file mode 100644 index 0000000..bd3ca13 --- /dev/null +++ b/flutter_blurhash/lib/flutter_blurhash.dart @@ -0,0 +1,5 @@ +library flutter_blurhash; + +export 'src/blurhash.dart'; +export 'src/blurhash_widget.dart'; +export 'src/blurhash_image.dart'; diff --git a/flutter_blurhash/lib/src/blurhash.dart b/flutter_blurhash/lib/src/blurhash.dart new file mode 100644 index 0000000..0902a23 --- /dev/null +++ b/flutter_blurhash/lib/src/blurhash.dart @@ -0,0 +1,378 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +/// Optimization modes for BlurHash decoder +enum BlurHashOptimizationMode { + /// Original algorithm + none, + + /// Optimized with better cache locality + standard, + + /// Approximation with faster sRGB conversion + cache locality + approximation +} + +// Optimized BlurHash decode implementation +Future optimizedBlurHashDecode({ + required String blurHash, + required int width, + required int height, + double punch = 1.0, + BlurHashOptimizationMode optimizationMode = BlurHashOptimizationMode.standard, +}) { + _validateBlurHash(blurHash); + + final sizeFlag = _decode83(blurHash[0]); + final numY = (sizeFlag / 9).floor() + 1; + final numX = (sizeFlag % 9) + 1; + + final quantisedMaximumValue = _decode83(blurHash[1]); + final maximumValue = (quantisedMaximumValue + 1) / 166; + + // Preallocate colors array with fixed size + final colors = List>.filled(numX * numY, [0, 0, 0]); + + // Decode DC component (first component) + final dcValue = _decode83(blurHash.substring(2, 6)); + colors[0] = _decodeDC(dcValue); + + // Decode AC components (remaining components) + final adjustedPunch = maximumValue * punch; + for (var i = 1; i < colors.length; i++) { + final value = _decode83(blurHash.substring(4 + i * 2, 6 + i * 2)); + colors[i] = _decodeAC(value, adjustedPunch); + } + + // Precalculate cosine values for x and y + final cosinesX = List>.generate( + numX, + (i) => List.generate( + width, + (x) => cos((pi * x * i) / width), + ), + ); + + final cosinesY = List>.generate( + numY, + (j) => List.generate( + height, + (y) => cos((pi * y * j) / height), + ), + ); + + final bytesPerRow = width * 4; + final pixels = Uint8List(bytesPerRow * height); + + // Process image in chunks to improve cache locality + const chunkSize = 32; + + // Process the image in tiles for better cache performance + for (int yChunk = 0; yChunk < height; yChunk += chunkSize) { + final yEnd = min(yChunk + chunkSize, height); + + for (int xChunk = 0; xChunk < width; xChunk += chunkSize) { + final xEnd = min(xChunk + chunkSize, width); + + for (int y = yChunk; y < yEnd; y++) { + int p = (y * width + xChunk) * 4; + + for (int x = xChunk; x < xEnd; x++) { + var r = 0.0, g = 0.0, b = 0.0; + + // Use precalculated cosine values + for (int j = 0; j < numY; j++) { + final cosY = cosinesY[j][y]; + + for (int i = 0; i < numX; i++) { + final basis = cosinesX[i][x] * cosY; + final color = colors[i + j * numX]; + + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + + // Convert linear RGB to sRGB space based on optimization mode + switch (optimizationMode) { + case BlurHashOptimizationMode.approximation: + pixels[p++] = _approximatedLinearTosRGB(r); + pixels[p++] = _approximatedLinearTosRGB(g); + pixels[p++] = _approximatedLinearTosRGB(b); + break; + case BlurHashOptimizationMode.standard: + case BlurHashOptimizationMode.none: + pixels[p++] = _linearTosRGB(r); + pixels[p++] = _linearTosRGB(g); + pixels[p++] = _linearTosRGB(b); + break; + } + + pixels[p++] = 255; // Alpha is always 255 + } + } + } + } + + return Future.value(pixels); +} + +// Create this once as a static variable +final List _sRGBLookupTable = _createSRGBLookupTable(256); + +List _createSRGBLookupTable(int size) { + final table = List.filled(size, 0); + for (int i = 0; i < size; i++) { + final v = i / (size - 1); + if (v <= 0.0031308) { + table[i] = v * 12.92; + } else { + table[i] = 1.055 * pow(v, 1 / 2.4) - 0.055; + } + } + return table; +} + +int _approximatedLinearTosRGB(double value) { + final v = max(0.0, min(1.0, value)); + + // Find the closest indices in the lookup table + final pos = v * (_sRGBLookupTable.length - 1); + final idx = pos.floor(); + final fract = pos - idx; + + // Edge case for the maximum value + if (idx >= _sRGBLookupTable.length - 1) { + return (_sRGBLookupTable[_sRGBLookupTable.length - 1] * 255 + 0.5).toInt(); + } + + // Linear interpolation between the two closest values + final result = + _sRGBLookupTable[idx] * (1 - fract) + _sRGBLookupTable[idx + 1] * fract; + return (result * 255 + 0.5).toInt(); +} + +Future blurHashDecode({ + required String blurHash, + required int width, + required int height, + double punch = 1.0, +}) { + _validateBlurHash(blurHash); + + final sizeFlag = _decode83(blurHash[0]); + final numY = (sizeFlag / 9).floor() + 1; + final numX = (sizeFlag % 9) + 1; + + final quantisedMaximumValue = _decode83(blurHash[1]); + final maximumValue = (quantisedMaximumValue + 1) / 166; + + final colors = []..length = numX * numY; + + for (var i = 0; i < colors.length; i++) { + if (i == 0) { + final value = _decode83(blurHash.substring(2, 6)); + colors[i] = _decodeDC(value); + } else { + final value = _decode83(blurHash.substring(4 + i * 2, 6 + i * 2)); + colors[i] = _decodeAC(value, maximumValue * punch); + } + } + + final bytesPerRow = width * 4; + final pixels = Uint8List(bytesPerRow * height); + + int p = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + var r = .0; + var g = .0; + var b = .0; + + for (int j = 0; j < numY; j++) { + for (int i = 0; i < numX; i++) { + final basis = cos((pi * x * i) / width) * cos((pi * y * j) / height); + var color = colors[i + j * numX]; + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + + final intR = _linearTosRGB(r); + final intG = _linearTosRGB(g); + final intB = _linearTosRGB(b); + + pixels[p++] = intR; + pixels[p++] = intG; + pixels[p++] = intB; + pixels[p++] = 255; + } + } + + return Future.value(pixels); +} + +Future blurHashDecodeImage({ + required String blurHash, + required int width, + required int height, + double punch = 1.0, + BlurHashOptimizationMode optimizationMode = BlurHashOptimizationMode.standard, +}) async { + _validateBlurHash(blurHash); + + final completer = Completer(); + + final Uint8List pixels; + if (optimizationMode != BlurHashOptimizationMode.none) { + pixels = await optimizedBlurHashDecode( + blurHash: blurHash, + width: width, + height: height, + punch: punch, + optimizationMode: optimizationMode, + ); + } else { + pixels = await blurHashDecode( + blurHash: blurHash, + width: width, + height: height, + punch: punch, + ); + } + + if (kIsWeb) { + completer.complete(_createBmp(pixels, width, height)); + } else { + ui.decodeImageFromPixels( + pixels, width, height, ui.PixelFormat.rgba8888, completer.complete); + } + + return completer.future; +} + +Future _createBmp(Uint8List pixels, int width, int height) async { + int size = (width * height * 4) + 122; + final bmp = Uint8List(size); + final ByteData header = bmp.buffer.asByteData(); + header.setUint8(0x0, 0x42); + header.setUint8(0x1, 0x4d); + header.setInt32(0x2, size, Endian.little); + header.setInt32(0xa, 122, Endian.little); + header.setUint32(0xe, 108, Endian.little); + header.setUint32(0x12, width, Endian.little); + header.setUint32(0x16, -height, Endian.little); + header.setUint16(0x1a, 1, Endian.little); + header.setUint32(0x1c, 32, Endian.little); + header.setUint32(0x1e, 3, Endian.little); + header.setUint32(0x22, width * height * 4, Endian.little); + header.setUint32(0x36, 0x000000ff, Endian.little); + header.setUint32(0x3a, 0x0000ff00, Endian.little); + header.setUint32(0x3e, 0x00ff0000, Endian.little); + header.setUint32(0x42, 0xff000000, Endian.little); + bmp.setRange(122, size, pixels); + final codec = await ui.instantiateImageCodec(bmp); + final frame = await codec.getNextFrame(); + return frame.image; +} + +double _sRGBToLinear(int value) { + final v = value / 255; + if (v <= 0.04045) { + return v / 12.92; + } else { + return pow((v + 0.055) / 1.055, 2.4) as double; + } +} + +int _linearTosRGB(double value) { + final v = max(0, min(1, value)); + if (v <= 0.0031308) { + return (v * 12.92 * 255 + 0.5).round(); + } else { + return ((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5).round(); + } +} + +void _validateBlurHash(String blurHash) { + if (blurHash.length < 6) { + throw Exception('The blurhash string must be at least 6 characters'); + } + + final sizeFlag = _decode83(blurHash[0]); + final numY = (sizeFlag / 9).floor() + 1; + final numX = (sizeFlag % 9) + 1; + + if (blurHash.length != 4 + 2 * numX * numY) { + throw Exception( + 'blurhash length mismatch: length is ${blurHash.length} but ' + 'it should be ${4 + 2 * numX * numY}'); + } +} + +int _sign(double n) => (n < 0 ? -1 : 1); + +num _signPow(double val, double exp) => _sign(val) * pow(val.abs(), exp); + +int _decode83(String str) { + var value = 0; + final units = str.codeUnits; + final digits = _digitCharacters.codeUnits; + for (var i = 0; i < units.length; i++) { + final code = units.elementAt(i); + final digit = digits.indexOf(code); + if (digit == -1) { + throw ArgumentError.value(str, 'str'); + } + value = value * 83 + digit; + } + return value; +} + +List _decodeDC(int value) { + final intR = value >> 16; + final intG = (value >> 8) & 255; + final intB = value & 255; + return [_sRGBToLinear(intR), _sRGBToLinear(intG), _sRGBToLinear(intB)]; +} + +List _decodeAC(int value, double maximumValue) { + final quantR = (value / (19 * 19)).floor(); + final quantG = (value / 19).floor() % 19; + final quantB = value % 19; + + final rgb = [ + _signPow((quantR - 9) / 9, 2.0) * maximumValue, + _signPow((quantG - 9) / 9, 2.0) * maximumValue, + _signPow((quantB - 9) / 9, 2.0) * maximumValue + ]; + + return rgb; +} + +bool validateBlurhash(String blurhash) { + if (blurhash.isEmpty || blurhash.length < 6) { + debugPrint('Blurhash should be at least 6 characters'); + return false; + } + + final sizeFlag = _decode83(blurhash[0]); + final y = ((sizeFlag / 9) + 1).floor(); + final x = (sizeFlag % 9) + 1; + + if (blurhash.length != 4 + 2 * x * y) { + debugPrint( + "blurhash length mismatch: length is ${blurhash.length} but it should be ${4 + 2 * x * y}"); + return false; + } + + return true; +} + +const _digitCharacters = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"; diff --git a/flutter_blurhash/lib/src/blurhash_image.dart b/flutter_blurhash/lib/src/blurhash_image.dart new file mode 100644 index 0000000..81a862b --- /dev/null +++ b/flutter_blurhash/lib/src/blurhash_image.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_blurhash/flutter_blurhash.dart'; + +const _DEFAULT_SIZE = 32; + +class BlurHashImage extends ImageProvider { + /// Creates an object that decodes a [blurHash] as an image. + /// + /// The arguments must not be null. + const BlurHashImage(this.blurHash, + {this.decodingWidth = _DEFAULT_SIZE, + this.decodingHeight = _DEFAULT_SIZE, + this.scale = 1.0}); + + /// The bytes to decode into an image. + final String blurHash; + + /// The scale to place in the [ImageInfo] object of the image. + final double scale; + + /// Decoding definition + final int decodingWidth; + + /// Decoding definition + final int decodingHeight; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + BlurHashImage key, ImageDecoderCallback decode) => + OneFrameImageStreamCompleter(_loadAsync(key)); + + Future _loadAsync(BlurHashImage key) async { + assert(key == this); + + final image = await blurHashDecodeImage( + blurHash: blurHash, + width: decodingWidth, + height: decodingHeight, + ); + return ImageInfo(image: image, scale: key.scale); + } + + @override + bool operator ==(Object other) => other.runtimeType != runtimeType + ? false + : other is BlurHashImage && + other.blurHash == blurHash && + other.scale == scale; + + @override + int get hashCode => Object.hash(blurHash.hashCode, scale); + + @override + String toString() => '$runtimeType($blurHash, scale: $scale)'; +} diff --git a/flutter_blurhash/lib/src/blurhash_widget.dart b/flutter_blurhash/lib/src/blurhash_widget.dart new file mode 100644 index 0000000..ee24cbd --- /dev/null +++ b/flutter_blurhash/lib/src/blurhash_widget.dart @@ -0,0 +1,248 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_blurhash/flutter_blurhash.dart'; + +const _DEFAULT_SIZE = 32; + +/// Displays the blurhash [hash] and the fades into the [image] over the course +/// of [duration]. +class BlurHash extends StatefulWidget { + const BlurHash({ + required this.hash, + Key? key, + this.color = Colors.blueGrey, + this.imageFit = BoxFit.fill, + this.decodingWidth = _DEFAULT_SIZE, + this.decodingHeight = _DEFAULT_SIZE, + this.image, + this.onDecoded, + this.onDisplayed, + this.onReady, + this.onStarted, + this.duration = const Duration(milliseconds: 1000), + this.httpHeaders = const {}, + this.curve = Curves.easeOut, + this.errorBuilder, + this.optimizationMode = BlurHashOptimizationMode.none, + }) : assert(decodingWidth > 0), + assert(decodingHeight != 0), + super(key: key); + + /// Callback when hash is decoded + final VoidCallback? onDecoded; + + /// Callback when hash is displayed. + final VoidCallback? onDisplayed; + + /// Callback when image is downloaded + final VoidCallback? onReady; + + /// Callback when image is downloaded + final VoidCallback? onStarted; + + /// Hash to decode + final String hash; + + /// Displayed background color before decoding + final Color color; + + /// How to fit decoded & downloaded image + final BoxFit imageFit; + + /// Decoding definition + final int decodingWidth; + + /// Decoding definition + final int decodingHeight; + + /// Remote resource to download + final String? image; + + final Duration duration; + + final Curve curve; + + /// Http headers for secure call like bearer + final Map httpHeaders; + + /// Network image errorBuilder + final ImageErrorWidgetBuilder? errorBuilder; + + /// The optimization mode to use for decoding + final BlurHashOptimizationMode optimizationMode; + + @override + BlurHashState createState() => BlurHashState(); +} + +class BlurHashState extends State { + late Future _image; + late bool loaded; + late bool loading; + + @override + void initState() { + super.initState(); + _init(); + } + + void _init() { + _decodeImage(); + loaded = false; + loading = false; + } + + @override + void didUpdateWidget(BlurHash oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hash != oldWidget.hash || + widget.image != oldWidget.image || + widget.decodingWidth != oldWidget.decodingWidth || + widget.decodingHeight != oldWidget.decodingHeight || + widget.optimizationMode != oldWidget.optimizationMode) { + _init(); + } + } + + void _decodeImage() { + _image = blurHashDecodeImage( + blurHash: widget.hash, + width: widget.decodingWidth, + height: widget.decodingHeight, + optimizationMode: widget.optimizationMode, + ); + + _image.whenComplete(() => widget.onDecoded?.call()); + } + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + buildBlurHashBackground(), + if (widget.image != null) prepareDisplayedImage(widget.image!), + ], + ); + + Widget prepareDisplayedImage(String image) => Image.network( + image, + fit: widget.imageFit, + headers: widget.httpHeaders, + errorBuilder: widget.errorBuilder, + loadingBuilder: (context, img, loadingProgress) { + // Download started + if (loading == false) { + loading = true; + widget.onStarted?.call(); + } + + if (loadingProgress == null) { + // Image is now loaded, trigger the event + loaded = true; + widget.onReady?.call(); + return _DisplayImage( + child: img, + duration: widget.duration, + curve: widget.curve, + onCompleted: () => widget.onDisplayed?.call(), + ); + } else { + return const SizedBox(); + } + }, + ); + + /// Decode the blurhash then display the resulting Image + Widget buildBlurHashBackground() => FutureBuilder( + future: _image, + builder: (ctx, snap) => snap.hasData + ? Image(image: UiImage(snap.data!), fit: widget.imageFit) + : Container(color: widget.color), + ); +} + +// Inner display details & controls +class _DisplayImage extends StatefulWidget { + final Widget child; + final Duration duration; + final Curve curve; + final VoidCallback onCompleted; + + const _DisplayImage({ + required this.child, + this.duration = const Duration(milliseconds: 800), + required this.curve, + required this.onCompleted, + Key? key, + }) : super(key: key); + + @override + _DisplayImageState createState() => _DisplayImageState(); +} + +class _DisplayImageState extends State<_DisplayImage> + with SingleTickerProviderStateMixin { + late Animation opacity; + late AnimationController controller; + + @override + Widget build(BuildContext context) => FadeTransition( + opacity: opacity, + child: widget.child, + ); + + @override + void initState() { + super.initState(); + controller = AnimationController(duration: widget.duration, vsync: this); + final curved = CurvedAnimation(parent: controller, curve: widget.curve); + opacity = Tween(begin: .0, end: 1.0).animate(curved); + controller.forward(); + + curved.addStatusListener((status) { + if (status == AnimationStatus.completed) widget.onCompleted.call(); + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } +} + +class UiImage extends ImageProvider { + final ui.Image image; + final double scale; + + const UiImage(this.image, {this.scale = 1.0}); + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage(UiImage key, ImageDecoderCallback decode) => + OneFrameImageStreamCompleter(_loadAsync(key)); + + Future _loadAsync(UiImage key) async { + assert(key == this); + return ImageInfo(image: image, scale: key.scale); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is UiImage && other.image == image && other.scale == scale; + } + + @override + int get hashCode => Object.hash(image.hashCode, scale); + + @override + String toString() => + '$runtimeType(${describeIdentity(image)}, scale: $scale)'; +} diff --git a/flutter_blurhash/pubspec.yaml b/flutter_blurhash/pubspec.yaml new file mode 100755 index 0000000..7de229b --- /dev/null +++ b/flutter_blurhash/pubspec.yaml @@ -0,0 +1,30 @@ +name: flutter_blurhash +description: > + Compact representation of placeholder for an image. + Encode a blurry image under 30 characters for instant display like used by Medium +version: 0.9.1 +homepage: https://github.com/fluttercommunity/flutter_blurhash +repository: https://github.com/fluttercommunity/flutter_blurhash +issue_tracker: https://github.com/fluttercommunity/flutter_blurhash/issues +maintainer: Robert Felker (@Solido) + +screenshots: + - description: 'Content preview as blurred buffer.' + path: screenshots/blurred.png + - description: 'Content loaded.' + path: screenshots/loaded.png + +environment: + # flutter: ">=3.16.0" + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + + flutter_lints: + + flutter_test: + sdk: flutter diff --git a/lottie/pubspec.yaml b/lottie/pubspec.yaml index 57adf2f..a869491 100644 --- a/lottie/pubspec.yaml +++ b/lottie/pubspec.yaml @@ -3,8 +3,6 @@ description: Render After Effects animations natively on Flutter. This package i version: 3.5.1 repository: https://github.com/xvrh/lottie-flutter -workspace: - - example funding: - https://www.buymeacoffee.com/xvrh diff --git a/pubspec.yaml b/pubspec.yaml index 20b10d0..ca1857f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,3 +1,3 @@ dependency_overrides: - lottie: - path: ./dependencies/lottie + flutter_blurhash: + path: ./dependencies/flutter_blurhash