mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-07-17 16:41:50 +00:00
148 lines
5.3 KiB
Dart
148 lines
5.3 KiB
Dart
import 'dart:async';
|
||
import 'dart:ui' as ui;
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_avif/flutter_avif.dart';
|
||
|
||
/// Compresses and resizes an image for low-bandwidth mesh transmission.
|
||
///
|
||
/// Target: ≤256×256 pixels, grayscale AVIF at aggressive quality.
|
||
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
|
||
/// → 7–20 fragments at 152 bytes each.
|
||
class ImageCodecService {
|
||
static const int _normalAvifSpeed = 6;
|
||
static const int _ultraAvifSpeed = 4;
|
||
|
||
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
|
||
/// small grayscale AVIF suitable for mesh transmission.
|
||
///
|
||
/// [maxDimension] caps width and height (default 256); aspect ratio is
|
||
/// preserved and images smaller than the cap are not upscaled.
|
||
/// [compression] 0 = lossless, 100 = smallest/worst (libavif CQ scale).
|
||
///
|
||
/// Returns `(bytes, width, height)` or null if decoding or encoding fails.
|
||
static Future<({Uint8List bytes, int width, int height})?> compress(
|
||
Uint8List rawBytes, {
|
||
int maxDimension = 256,
|
||
int compression = 90,
|
||
bool grayscale = true,
|
||
bool ultraMode = false,
|
||
}) async {
|
||
try {
|
||
final effectiveMaxDimension = maxDimension.clamp(32, 1024);
|
||
final effectiveCompression = ultraMode
|
||
? (compression + 12).clamp(10, 100)
|
||
: compression.clamp(10, 100);
|
||
final forceGrayscale = ultraMode ? true : grayscale;
|
||
|
||
// 1a. Probe original dimensions (no resize).
|
||
final probeCodec = await ui.instantiateImageCodec(rawBytes);
|
||
final probeFrame = await probeCodec.getNextFrame();
|
||
final srcW = probeFrame.image.width;
|
||
final srcH = probeFrame.image.height;
|
||
probeFrame.image.dispose();
|
||
|
||
// 1b. Compute contain dimensions: scale down only the limiting axis so
|
||
// the image fits within maxDimension×maxDimension without stretching.
|
||
int dstW = srcW;
|
||
int dstH = srcH;
|
||
if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) {
|
||
if (srcW >= srcH) {
|
||
dstW = effectiveMaxDimension;
|
||
dstH = (srcH * effectiveMaxDimension / srcW).round().clamp(
|
||
1,
|
||
effectiveMaxDimension,
|
||
);
|
||
} else {
|
||
dstH = effectiveMaxDimension;
|
||
dstW = (srcW * effectiveMaxDimension / srcH).round().clamp(
|
||
1,
|
||
effectiveMaxDimension,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 1c. Decode at the exact contain size (single axis constrained).
|
||
final codec = await ui.instantiateImageCodec(
|
||
rawBytes,
|
||
targetWidth: dstW,
|
||
targetHeight: dstH,
|
||
allowUpscaling: false,
|
||
);
|
||
final frame = await codec.getNextFrame();
|
||
final image = frame.image;
|
||
|
||
final w = image.width;
|
||
final h = image.height;
|
||
|
||
// 2. Export RGBA pixels.
|
||
final byteData = await image.toByteData(
|
||
format: ui.ImageByteFormat.rawRgba,
|
||
);
|
||
image.dispose();
|
||
if (byteData == null) return null;
|
||
|
||
// 3. Optionally convert to grayscale in-place (luminance).
|
||
final rgba = byteData.buffer.asUint8List();
|
||
if (forceGrayscale) {
|
||
for (var i = 0; i < rgba.length; i += 4) {
|
||
final lum =
|
||
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
|
||
.round()
|
||
.clamp(0, 255);
|
||
rgba[i] = lum;
|
||
rgba[i + 1] = lum;
|
||
rgba[i + 2] = lum;
|
||
rgba[i + 3] = 255;
|
||
}
|
||
}
|
||
|
||
// 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it.
|
||
// encodeAvif() takes an encoded image (PNG/JPEG), not raw RGBA.
|
||
final buffer = await ui.ImmutableBuffer.fromUint8List(rgba);
|
||
final descriptor = ui.ImageDescriptor.raw(
|
||
buffer,
|
||
width: w,
|
||
height: h,
|
||
pixelFormat: ui.PixelFormat.rgba8888,
|
||
);
|
||
final greyCodec = await descriptor.instantiateCodec();
|
||
final greyFrame = await greyCodec.getNextFrame();
|
||
final greyImage = greyFrame.image;
|
||
final pngData = await greyImage.toByteData(
|
||
format: ui.ImageByteFormat.png,
|
||
);
|
||
greyImage.dispose();
|
||
if (pngData == null) return null;
|
||
final pngBytes = pngData.buffer.asUint8List();
|
||
|
||
// 5. Encode PNG → AVIF.
|
||
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
|
||
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37.
|
||
final maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63);
|
||
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
|
||
final avif = await encodeAvif(
|
||
pngBytes,
|
||
maxThreads: 2,
|
||
maxQuantizer: maxQ,
|
||
minQuantizer: minQ,
|
||
// We force fully opaque alpha, so alpha can be quantized aggressively.
|
||
maxQuantizerAlpha: 63,
|
||
minQuantizerAlpha: 63,
|
||
// Slower speed improves compression efficiency at similar quality.
|
||
speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed,
|
||
keepExif: false,
|
||
);
|
||
if (avif.isEmpty) return null;
|
||
|
||
debugPrint(
|
||
'📷 [ImageCodec] ${rawBytes.length}B → $w×$h grayscale AVIF '
|
||
'${avif.length}B (${(avif.length * 100 / rawBytes.length).round()}%)',
|
||
);
|
||
return (bytes: avif, width: w, height: h);
|
||
} catch (e, st) {
|
||
debugPrint('❌ [ImageCodec] compress error: $e\n$st');
|
||
return null;
|
||
}
|
||
}
|
||
}
|