| 1 | import 'dart:io'; |
| 2 | import 'dart:typed_data'; |
| 3 | import 'dart:ui' as ui; |
| 4 | |
| 5 | enum ImageCompressionLevel { low, medium, high } |
| 6 | |
| 7 | class _CompressionConfig { |
| 8 | const _CompressionConfig(this.maxPayloadBytes, this.dimensions); |
| 9 | |
| 10 | final int maxPayloadBytes; |
| 11 | final List<int> dimensions; |
| 12 | } |
| 13 | |
| 14 | /// Resize/compress images so vision-model requests stay under payload limits. |
| 15 | class ImageUploadUtils { |
| 16 | ImageUploadUtils._(); |
| 17 | |
| 18 | /// Kept for callers that don't care about the configured level. |
| 19 | static const maxPayloadBytes = 750000; |
| 20 | static const maxBase64Chars = 1000000; |
| 21 | |
| 22 | static const Map<ImageCompressionLevel, _CompressionConfig> _configs = { |
| 23 | ImageCompressionLevel.low: _CompressionConfig(1500000, [ |
| 24 | 1920, |
| 25 | 1536, |
| 26 | 1280, |
| 27 | 1024, |
| 28 | ]), |
| 29 | ImageCompressionLevel.medium: _CompressionConfig(750000, [ |
| 30 | 1536, |
| 31 | 1280, |
| 32 | 1024, |
| 33 | 768, |
| 34 | 512, |
| 35 | ]), |
| 36 | ImageCompressionLevel.high: _CompressionConfig(400000, [ |
| 37 | 1024, |
| 38 | 768, |
| 39 | 512, |
| 40 | 384, |
| 41 | 256, |
| 42 | ]), |
| 43 | }; |
| 44 | |
| 45 | static Future<Uint8List> prepareImageBytes( |
| 46 | File file, { |
| 47 | bool enabled = true, |
| 48 | ImageCompressionLevel level = ImageCompressionLevel.medium, |
| 49 | }) async { |
| 50 | var bytes = await file.readAsBytes(); |
| 51 | if (!enabled) return bytes; |
| 52 | |
| 53 | final config = _configs[level]!; |
| 54 | if (bytes.length <= config.maxPayloadBytes) return bytes; |
| 55 | |
| 56 | for (final dimension in config.dimensions) { |
| 57 | final resized = await _resizeToPng(bytes, dimension); |
| 58 | if (resized.length <= config.maxPayloadBytes) return resized; |
| 59 | bytes = resized; |
| 60 | } |
| 61 | return bytes; |
| 62 | } |
| 63 | |
| 64 | static Future<File> prepareImageFile( |
| 65 | File source, { |
| 66 | bool enabled = true, |
| 67 | ImageCompressionLevel level = ImageCompressionLevel.medium, |
| 68 | }) async { |
| 69 | final bytes = await prepareImageBytes( |
| 70 | source, |
| 71 | enabled: enabled, |
| 72 | level: level, |
| 73 | ); |
| 74 | final original = source.path.split(Platform.pathSeparator).last; |
| 75 | final dot = original.lastIndexOf('.'); |
| 76 | final base = dot > 0 ? original.substring(0, dot) : original; |
| 77 | final outPath = |
| 78 | '${source.parent.path}${Platform.pathSeparator}upload_${DateTime.now().millisecondsSinceEpoch}_$base.png'; |
| 79 | final out = File(outPath); |
| 80 | await out.writeAsBytes(bytes); |
| 81 | return out; |
| 82 | } |
| 83 | |
| 84 | static Future<Uint8List> _resizeToPng( |
| 85 | Uint8List bytes, |
| 86 | int maxDimension, |
| 87 | ) async { |
| 88 | final decodeCodec = await ui.instantiateImageCodec(bytes); |
| 89 | final decodeFrame = await decodeCodec.getNextFrame(); |
| 90 | final origWidth = decodeFrame.image.width; |
| 91 | final origHeight = decodeFrame.image.height; |
| 92 | decodeFrame.image.dispose(); |
| 93 | int targetWidth, targetHeight; |
| 94 | if (origWidth >= origHeight) { |
| 95 | targetWidth = maxDimension; |
| 96 | targetHeight = (origHeight * maxDimension / origWidth).round().clamp( |
| 97 | 1, |
| 98 | maxDimension, |
| 99 | ); |
| 100 | } else { |
| 101 | targetHeight = maxDimension; |
| 102 | targetWidth = (origWidth * maxDimension / origHeight).round().clamp( |
| 103 | 1, |
| 104 | maxDimension, |
| 105 | ); |
| 106 | } |
| 107 | final codec = await ui.instantiateImageCodec( |
| 108 | bytes, |
| 109 | targetWidth: targetWidth, |
| 110 | targetHeight: targetHeight, |
| 111 | ); |
| 112 | final frame = await codec.getNextFrame(); |
| 113 | final data = await frame.image.toByteData(format: ui.ImageByteFormat.png); |
| 114 | frame.image.dispose(); |
| 115 | return data?.buffer.asUint8List() ?? bytes; |
| 116 | } |
| 117 | } |