From 6bd8bf5297d345bcbba0d1a19c50283e4cd50529 Mon Sep 17 00:00:00 2001 From: Guillaume LE MARTRET Date: Fri, 20 Dec 2024 16:07:21 +0100 Subject: [PATCH] feat: add progressive JPEG decoding --- .../lib/src/result/interlaced_progress.dart | 10 +++ .../lib/src/result/result.dart | 1 + .../interlaced/interlaced_transformer.dart | 76 +++++++++++++++++++ .../interlaced/progressive_jpeg_decoder.dart | 73 ++++++++++++++++++ .../lib/src/web/web_helper.dart | 39 +++++++--- 5 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 flutter_cache_manager/lib/src/result/interlaced_progress.dart create mode 100644 flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart create mode 100644 flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart diff --git a/flutter_cache_manager/lib/src/result/interlaced_progress.dart b/flutter_cache_manager/lib/src/result/interlaced_progress.dart new file mode 100644 index 00000000..3c85ce12 --- /dev/null +++ b/flutter_cache_manager/lib/src/result/interlaced_progress.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class InterlacedProgress extends DownloadProgress { + InterlacedProgress( + super.originalUrl, super.totalSize, super.downloaded, this.data); + + final Uint8List data; +} diff --git a/flutter_cache_manager/lib/src/result/result.dart b/flutter_cache_manager/lib/src/result/result.dart index fba37a7f..60dc84c3 100644 --- a/flutter_cache_manager/lib/src/result/result.dart +++ b/flutter_cache_manager/lib/src/result/result.dart @@ -1,3 +1,4 @@ export 'download_progress.dart'; export 'file_info.dart'; export 'file_response.dart'; +export 'interlaced_progress.dart'; diff --git a/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart new file mode 100644 index 00000000..1a7622d3 --- /dev/null +++ b/flutter_cache_manager/lib/src/web/interlaced/interlaced_transformer.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/src/web/interlaced/progressive_jpeg_decoder.dart'; + +class InterlacedData { + final Uint8List data; + InterlacedData(this.data); +} + +class InterlacedConverter extends Converter, InterlacedData> { + const InterlacedConverter(); + + @override + InterlacedData convert(List input) => + InterlacedData(Uint8List.fromList(input)); + + @override + Sink startChunkedConversion(Sink sink) => + InterlacedByteConversionSink(sink); +} + +class InterlacedByteConversionSink implements ChunkedConversionSink { + final Sink _output; + + // Buffer to accumulate chunks + BytesBuilder? _buffer = BytesBuilder(); + + InterlacedDecoder? _decoder; + + InterlacedByteConversionSink(this._output); + + @override + void add(List chunk) { + // Ensure buffer is not null (should not happen in normal flow) + final buffer = _buffer; + if (buffer == null) { + throw StateError('Sink has been closed and cannot accept new data.'); + } + + _decoder ??= resolveDecoder(); + + if (_decoder == null) { + return _buffer!.add(chunk); + } + + final interlacedData = _decoder?.addChunk(chunk); + if (interlacedData != null) { + _output.add(interlacedData); + } + } + + @override + void close() { + _buffer?.clear(); + _buffer = null; + _decoder = null; + _output.close(); + } + + InterlacedDecoder? resolveDecoder() { + if (ProgressiveJPEGDecoder.isProgressiveJPEG(_buffer)) { + return ProgressiveJPEGDecoder(_buffer!); + } + return null; + } +} + +// Base class for interlaced format decoders +abstract class InterlacedDecoder { + final BytesBuilder buffer; + + InterlacedDecoder(this.buffer); + + InterlacedData? addChunk(List chunk); +} diff --git a/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart new file mode 100644 index 00000000..b73795ab --- /dev/null +++ b/flutter_cache_manager/lib/src/web/interlaced/progressive_jpeg_decoder.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/src/web/interlaced/interlaced_transformer.dart'; + +// Decoder for progressive JPEG images +class ProgressiveJPEGDecoder extends InterlacedDecoder { + static bool isProgressiveJPEG(BytesBuilder? buffer) { + if (buffer == null) return false; + + final data = buffer.toBytes(); + + if (data.length < 4) return false; + + // Check for the SOI (Start of Image) + if (data[0] == 0xFF && data[1] == 0xD8) { + // Check for the first SOF marker + for (int i = 2; i < data.length - 1; i++) { + if (data[i] == 0xFF && data[i + 1] >= 0xC0 && data[i + 1] <= 0xCF) { + return data[i + 1] == 0xC2; + } + } + } + + return false; + } + + // List of valid offsets + final List _validOffsets = []; + + ProgressiveJPEGDecoder(super.buffer); + + @override + InterlacedData? addChunk(List chunk) { + // Calculate startOffset before adding the new chunk + int startOffset = + (buffer.length - 1).clamp(0, buffer.length); // Ensure valid bounds + + // Add the new chunk to the buffer + buffer.add(chunk); + + _updateOffsets(buffer.toBytes(), startOffset); + + return _getBestData(); + } + + InterlacedData? _getBestData() { + // Get the best valid data using the latest valid offset + if (_validOffsets.isNotEmpty) { + final data = + Uint8List.sublistView(buffer.toBytes(), 0, _validOffsets.last); + + data[data.length - 1] = 0xd9; // Fix the last byte as EOI + return InterlacedData(data); + } + + return null; + } + + void _updateOffsets(Uint8List data, int startOffset) { + // Iterate through data starting from the adjusted offset + for (int i = startOffset; i < data.length - 1; i++) { + if (data[i] == 0xFF && _isMarker(data[i + 1])) { + // Add offset of the marker's end + _validOffsets.add(i + 2); + } + } + } + + bool _isMarker(int byte) { + // Check for JPEG markers: 0xDA (Start of Scan) or 0xD9 (End of Image) + return byte == 0xDA || byte == 0xD9; + } +} diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index 20685484..8144d45b 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -6,6 +6,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/cache_store.dart'; +import 'package:flutter_cache_manager/src/web/interlaced/interlaced_transformer.dart'; import 'package:flutter_cache_manager/src/web/queue_item.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; @@ -131,11 +132,30 @@ class WebHelper { var newCacheObject = _setDataFromHeaders(cacheObject, response); if (statusCodesNewFile.contains(response.statusCode)) { var savedBytes = 0; - await for (final progress in _saveFile(newCacheObject, response)) { - savedBytes = progress; - yield DownloadProgress( - cacheObject.url, response.contentLength, progress); + final chunkStream = + _saveFile(newCacheObject, response).asBroadcastStream(); + + final stream = MergeStream([ + chunkStream, + chunkStream + .transform(const InterlacedConverter()) + .distinctUnique( + equals: (a, b) => a.data.length == b.data.length, + hashCode: (e) => e.data.length, + ), + ]); + + await for (final progress in stream) { + if (progress is InterlacedData) { + yield InterlacedProgress(cacheObject.url, response.contentLength, + progress.data.length, progress.data); + } else if (progress is List) { + savedBytes += progress.length; + yield DownloadProgress( + cacheObject.url, response.contentLength, savedBytes); + } } + newCacheObject = newCacheObject.copyWith(length: savedBytes); } @@ -177,8 +197,9 @@ class WebHelper { ); } - Stream _saveFile(CacheObject cacheObject, FileServiceResponse response) { - final receivedBytesResultController = StreamController(); + Stream> _saveFile( + CacheObject cacheObject, FileServiceResponse response) { + final receivedBytesResultController = StreamController>(); _saveFileAndPostUpdates( receivedBytesResultController, cacheObject, @@ -188,17 +209,15 @@ class WebHelper { } Future _saveFileAndPostUpdates( - StreamController receivedBytesResultController, + StreamController> receivedBytesResultController, CacheObject cacheObject, FileServiceResponse response) async { final file = await _store.fileSystem.createFile(cacheObject.relativePath); try { - var receivedBytes = 0; final sink = file.openWrite(); await response.content.map((s) { - receivedBytes += s.length; - receivedBytesResultController.add(receivedBytes); + receivedBytesResultController.add(s); return s; }).pipe(sink); } on Object catch (e, stacktrace) {