add blurhash

This commit is contained in:
otsmr 2026-07-19 20:19:56 +02:00
parent d99dba6a5e
commit 32882a3190
10 changed files with 752 additions and 5 deletions

View file

@ -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

View file

@ -66,4 +66,8 @@ exif:
git: https://github.com/bigflood/dartexif.git
dependencies:
sprintf:
git: https://github.com/Naddiseo/dart-sprintf.git
git: https://github.com/Naddiseo/dart-sprintf.git
flutter_blurhash:
git: https://github.com/fluttercommunity/flutter_blurhash.git

21
flutter_blurhash/LICENSE Executable file
View file

@ -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.

View file

@ -0,0 +1,5 @@
library flutter_blurhash;
export 'src/blurhash.dart';
export 'src/blurhash_widget.dart';
export 'src/blurhash_image.dart';

View file

@ -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<Uint8List> 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<List<double>>.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<List<double>>.generate(
numX,
(i) => List<double>.generate(
width,
(x) => cos((pi * x * i) / width),
),
);
final cosinesY = List<List<double>>.generate(
numY,
(j) => List<double>.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<double> _sRGBLookupTable = _createSRGBLookupTable(256);
List<double> _createSRGBLookupTable(int size) {
final table = List<double>.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<Uint8List> 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<ui.Image> blurHashDecodeImage({
required String blurHash,
required int width,
required int height,
double punch = 1.0,
BlurHashOptimizationMode optimizationMode = BlurHashOptimizationMode.standard,
}) async {
_validateBlurHash(blurHash);
final completer = Completer<ui.Image>();
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<ui.Image> _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<double> _decodeDC(int value) {
final intR = value >> 16;
final intG = (value >> 8) & 255;
final intB = value & 255;
return [_sRGBToLinear(intR), _sRGBToLinear(intG), _sRGBToLinear(intB)];
}
List<double> _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#\$%*+,-.:;=?@[]^_{|}~";

View file

@ -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<BlurHashImage> {
/// 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<BlurHashImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<BlurHashImage>(this);
@override
ImageStreamCompleter loadImage(
BlurHashImage key, ImageDecoderCallback decode) =>
OneFrameImageStreamCompleter(_loadAsync(key));
Future<ImageInfo> _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)';
}

View file

@ -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<String, String> 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<BlurHash> {
late Future<ui.Image> _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<ui.Image>(
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<double> 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<double>(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<UiImage> {
final ui.Image image;
final double scale;
const UiImage(this.image, {this.scale = 1.0});
@override
Future<UiImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<UiImage>(this);
@override
ImageStreamCompleter loadImage(UiImage key, ImageDecoderCallback decode) =>
OneFrameImageStreamCompleter(_loadAsync(key));
Future<ImageInfo> _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)';
}

30
flutter_blurhash/pubspec.yaml Executable file
View file

@ -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

View file

@ -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

View file

@ -1,3 +1,3 @@
dependency_overrides:
lottie:
path: ./dependencies/lottie
flutter_blurhash:
path: ./dependencies/flutter_blurhash