Files
MrbWebLibre/apps/weblibre/lib/features/geckoview/utils/image_helper.dart
T
Codex aab2bc0cee
Pixel 10 APK / build (push) Successful in 37m12s
perf(tabs): release stale image and tab state
2026-08-10 17:59:50 +02:00

266 lines
7.4 KiB
Dart

/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:fast_equatable/hash.dart';
import 'package:flutter_svg/flutter_svg.dart' as svg;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/utils/lru_cache.dart';
final _cache = LRUCache<ImageIdentity, EquatableImage>(100);
const _defaultSvgIconSize = 32;
/// Clears the global image decode cache.
void clearImageCache() {
_cache.clear();
}
Future<EquatableImage?> tryDecodeImage(
Uint8List bytes, {
int? targetWidth,
int? targetHeight,
bool allowUpscaling = true,
bool cacheResult = true,
}) async {
// The decode options are part of the identity of the result, not just of the
// request: the same bytes decoded at a thumbnail's target width and at an
// icon's native size are different images. Keying on the content digest
// alone would both hand back a wrongly-sized image to whichever caller ran
// second, and make two genuinely different decodes compare equal.
final identity = (
digest: secureHash(bytes),
targetWidth: targetWidth,
targetHeight: targetHeight,
allowUpscaling: allowUpscaling,
);
if (cacheResult) {
final cached = _cache.get(identity);
if (cached?.value != null) {
return cached;
} else if (cached != null) {
_cache.remove(identity);
}
}
try {
final codec = await instantiateImageCodec(
bytes,
targetWidth: targetWidth,
targetHeight: targetHeight,
allowUpscaling: allowUpscaling,
);
final frameInfo = await codec.getNextFrame();
final image = EquatableImage(frameInfo.image, identity: identity);
if (image.value != null && image.value!.width > 0) {
if (cacheResult) {
_cache.set(identity, image);
}
return image;
}
} catch (e, s) {
if (_isLikelySvg(bytes)) {
try {
final svgImage = await _tryDecodeSvg(
bytes,
identity: identity,
targetWidth: targetWidth,
targetHeight: targetHeight,
);
if (svgImage != null) {
if (cacheResult) {
_cache.set(identity, svgImage);
}
return svgImage;
}
} catch (svgError, svgStackTrace) {
logger.w(
'Failed to rasterize SVG image',
error: svgError,
stackTrace: svgStackTrace,
);
}
}
logger.w('Failed to decode image', error: e, stackTrace: s);
}
return null;
}
/// Transcodes a native screenshot (delivered as WebP) to PNG off the main
/// isolate.
///
/// Decoding a full-resolution screenshot and re-encoding it as PNG costs tens
/// of milliseconds on the UI thread, and every caller does it right as a share
/// sheet or file picker is animating in — so the cost lands as a visible hitch.
/// dart:ui's codec APIs are usable from background isolates, so the whole
/// transcode runs there; if that ever fails we fall back to doing it inline
/// rather than losing the feature.
Future<Uint8List?> encodeScreenshotAsPng(Uint8List screenshot) async {
try {
return await Isolate.run(() => _transcodeToPng(screenshot));
} catch (e, s) {
logger.w(
'Screenshot PNG transcode failed in background isolate, retrying inline',
error: e,
stackTrace: s,
);
return _transcodeToPng(screenshot);
}
}
Future<Uint8List?> _transcodeToPng(Uint8List bytes) async {
final codec = await instantiateImageCodec(bytes);
try {
final frameInfo = await codec.getNextFrame();
try {
final png = await frameInfo.image.toByteData(format: ImageByteFormat.png);
return png?.buffer.asUint8List();
} finally {
frameInfo.image.dispose();
}
} finally {
codec.dispose();
}
}
bool _isLikelySvg(Uint8List bytes) {
final prefix = utf8
.decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true)
.trimLeft();
final normalized = prefix.isNotEmpty && prefix.codeUnitAt(0) == 0xFEFF
? prefix.substring(1)
: prefix;
final lower = normalized.toLowerCase();
return lower.contains('<svg') ||
lower.contains('<!doctype svg') ||
(lower.contains('<?xml') && lower.contains('<svg'));
}
Future<EquatableImage?> _tryDecodeSvg(
Uint8List bytes, {
required ImageIdentity identity,
int? targetWidth,
int? targetHeight,
}) async {
final pictureInfo = await svg.vg.loadPicture(svg.SvgBytesLoader(bytes), null);
try {
final dimensions = _resolveSvgRasterDimensions(
pictureInfo.size,
targetWidth: targetWidth,
targetHeight: targetHeight,
);
final sourceWidth = pictureInfo.size.width > 0
? pictureInfo.size.width
: dimensions.width.toDouble();
final sourceHeight = pictureInfo.size.height > 0
? pictureInfo.size.height
: dimensions.height.toDouble();
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.clipRect(
Rect.fromLTWH(
0,
0,
dimensions.width.toDouble(),
dimensions.height.toDouble(),
),
);
canvas.scale(
dimensions.width / sourceWidth,
dimensions.height / sourceHeight,
);
canvas.drawPicture(pictureInfo.picture);
final scaledPicture = recorder.endRecording();
try {
final rasterized = await scaledPicture.toImage(
dimensions.width,
dimensions.height,
);
return EquatableImage(rasterized, identity: identity);
} finally {
scaledPicture.dispose();
}
} finally {
pictureInfo.picture.dispose();
}
}
({int width, int height}) _resolveSvgRasterDimensions(
Size sourceSize, {
int? targetWidth,
int? targetHeight,
}) {
if (targetWidth != null && targetHeight != null) {
return (width: max(1, targetWidth), height: max(1, targetHeight));
}
final fallbackSize = targetWidth ?? targetHeight ?? _defaultSvgIconSize;
final sourceWidth = sourceSize.width > 0
? sourceSize.width
: fallbackSize.toDouble();
final sourceHeight = sourceSize.height > 0
? sourceSize.height
: fallbackSize.toDouble();
if (targetWidth != null) {
return (
width: max(1, targetWidth),
height: max(1, (targetWidth * sourceHeight / sourceWidth).round()),
);
}
if (targetHeight != null) {
return (
width: max(1, (targetHeight * sourceWidth / sourceHeight).round()),
height: max(1, targetHeight),
);
}
if (sourceWidth >= sourceHeight) {
return (
width: fallbackSize,
height: max(1, (fallbackSize * sourceHeight / sourceWidth).round()),
);
}
return (
width: max(1, (fallbackSize * sourceWidth / sourceHeight).round()),
height: fallbackSize,
);
}