mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-28 11:27:59 +00:00
240 lines
7.4 KiB
Dart
240 lines
7.4 KiB
Dart
/*
|
||
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||
SPDX-License-Identifier: MIT
|
||
*/
|
||
|
||
import 'dart:convert';
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flex_color_picker/flex_color_picker.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:image/image.dart' as img;
|
||
|
||
extension HexExtension on Uint8List {
|
||
String toHexString() {
|
||
return map(
|
||
(byte) => byte.toRadixString(16).padLeft(2, '0'),
|
||
).join().toUpperCase();
|
||
}
|
||
}
|
||
|
||
// 定义NeedReplaceKeyValueFor
|
||
final projectStringReplacement = {
|
||
"小智": "Xiaozhi",
|
||
"Qwen3 实时": "Qwen3 235B (Fast)",
|
||
"DeepSeek V3.1": "DeepSeek V3.1 (Powerful)",
|
||
"DouBao Seed 1.6": "Doubao Seed 1.6 (Delayed)",
|
||
"GLM 4.7(内测)": "GLM 4.7(Internal Test)",
|
||
"Kimi-K2(内测)": "Kimi-K2(Internal Test)",
|
||
"Doubao 2.0(内测)": "Doubao 2.0(Internal Test)",
|
||
"Qwen3.5 397B(内测)": "Qwen3.5 397B(Internal Test)",
|
||
};
|
||
|
||
extension StringTool on String? {
|
||
/// Regex批量ReplaceString
|
||
/// 按照 projectStringReplacement 定义规ThenReplaceAllMatchContent
|
||
String? regularExpressionSubstitution() {
|
||
// 1. NullValueDirectlyReturns null
|
||
if (this == null) {
|
||
return null;
|
||
}
|
||
|
||
// 2. 拿到Non-NullString
|
||
String result = this!;
|
||
|
||
// 3. IterateReplaceDictionary,逐个ReplaceAllMatchItem
|
||
for (final entry in projectStringReplacement.entries) {
|
||
// Escape特殊字符,AvoidRegex报错(比如括号,点号等)
|
||
final pattern = RegExp.escape(entry.key);
|
||
// GlobalReplaceAllMatchContent
|
||
result = result.replaceAll(RegExp(pattern), entry.value);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
extension StringToUint8List on String? {
|
||
///Convert String? to Uint8List
|
||
Uint8List toUint8List() {
|
||
if (this == null || this!.isEmpty) {
|
||
return Uint8List(0);
|
||
}
|
||
return Uint8List.fromList(utf8.encode(this!));
|
||
}
|
||
|
||
///Convert Hex string to Color object
|
||
///Supported formats: "0xFFFFFFFF", "#FFFFFF", "FFFFFF"
|
||
Color hex() {
|
||
if (this == null || this!.isEmpty) return CupertinoColors.transparent;
|
||
|
||
String hexString = this!.toUpperCase().replaceAll("#", "");
|
||
if (hexString.startsWith("0X")) {
|
||
hexString = hexString.substring(2);
|
||
}
|
||
|
||
if (hexString.length == 6) {
|
||
hexString = "FF$hexString";
|
||
}
|
||
|
||
final intValue = int.tryParse(hexString, radix: 16);
|
||
return Color(intValue ?? 0x00000000);
|
||
}
|
||
}
|
||
|
||
extension ColorExtension on Color? {
|
||
///Convert Color to hex string (e.g., #RRGGBB)
|
||
String hexString() {
|
||
if (this == null) return "#000000";
|
||
|
||
//Extract RGB channels and convert to hex, ignore Alpha to match standard color codes
|
||
String r = this!.red8bit.toRadixString(16).padLeft(2, '0');
|
||
String g = this!.green8bit.toRadixString(16).padLeft(2, '0');
|
||
String b = this!.blue8bit.toRadixString(16).padLeft(2, '0');
|
||
|
||
return "#$r$g$b".toUpperCase();
|
||
}
|
||
}
|
||
|
||
extension ImageExtension on Uint8List {
|
||
Future<Uint8List?> compress({
|
||
ui.Size? resolutionSize,
|
||
double? memorySize,
|
||
bool cropCenter = false,
|
||
}) async {
|
||
//Use compute isolation to avoid blocking UI thread when processing large images
|
||
return compute(
|
||
_compressImage,
|
||
_CompressParams(
|
||
bytes: this,
|
||
resolutionSize: resolutionSize,
|
||
memorySize: memorySize,
|
||
cropCenter: cropCenter,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<Uint8List?> compressToMemorySize(double memorySize) async {
|
||
return compress(
|
||
resolutionSize: null,
|
||
memorySize: memorySize,
|
||
cropCenter: false,
|
||
);
|
||
}
|
||
}
|
||
|
||
//Compression parameter wrapper (for compute isolation)
|
||
class _CompressParams {
|
||
final Uint8List bytes;
|
||
final ui.Size? resolutionSize;
|
||
final double? memorySize;
|
||
final bool cropCenter;
|
||
|
||
_CompressParams({
|
||
required this.bytes,
|
||
this.resolutionSize,
|
||
this.memorySize,
|
||
required this.cropCenter,
|
||
});
|
||
}
|
||
|
||
//Core compression logic (top-level function for compute isolation)
|
||
Future<Uint8List?> _compressImage(_CompressParams params) async {
|
||
try {
|
||
//1. Decode original image
|
||
img.Image? originalImage = img.decodeImage(params.bytes);
|
||
if (originalImage == null) return null; //Return null on decode failure
|
||
|
||
img.Image processedImage = originalImage;
|
||
|
||
//2. Handle resolution scaling/cropping (align with iOS logic)
|
||
if (params.resolutionSize != null) {
|
||
final targetWidth = params.resolutionSize!.width.toInt();
|
||
final targetHeight = params.resolutionSize!.height.toInt();
|
||
|
||
if (params.cropCenter) {
|
||
//CropCenter=true: Scale to cover target size then center crop (Aspect-Fill)
|
||
final scaleX = targetWidth / originalImage.width;
|
||
final scaleY = targetHeight / originalImage.height;
|
||
final scale = scaleX > scaleY
|
||
? scaleX
|
||
: scaleY; //Take larger scale ratio
|
||
|
||
//Scale image to cover target size
|
||
final scaledWidth = (originalImage.width * scale).toInt();
|
||
final scaledHeight = (originalImage.height * scale).toInt();
|
||
final scaledImage = img.copyResize(
|
||
originalImage,
|
||
width: scaledWidth,
|
||
height: scaledHeight,
|
||
);
|
||
|
||
//Calculate center crop offset
|
||
final cropX = (scaledWidth - targetWidth) ~/ 2;
|
||
final cropY = (scaledHeight - targetHeight) ~/ 2;
|
||
|
||
//Execute center crop
|
||
processedImage = img.copyCrop(
|
||
scaledImage,
|
||
x: cropX,
|
||
y: cropY,
|
||
width: targetWidth,
|
||
height: targetHeight,
|
||
);
|
||
} else {
|
||
//CropCenter=false: Aspect-Fit scaling, draw to target size canvas
|
||
final scaleX = targetWidth / originalImage.width;
|
||
final scaleY = targetHeight / originalImage.height;
|
||
final scale = scaleX < scaleY
|
||
? scaleX
|
||
: scaleY; //Take smaller scale ratio
|
||
|
||
//Scale image to fit target size
|
||
final newWidth = (originalImage.width * scale).toInt();
|
||
final newHeight = (originalImage.height * scale).toInt();
|
||
final scaledImage = img.copyResize(
|
||
originalImage,
|
||
width: newWidth,
|
||
height: newHeight,
|
||
);
|
||
|
||
//Create target size canvas, draw scaled image at top-left (align with iOS draw logic)
|
||
final canvas = img.Image(width: targetWidth, height: targetHeight);
|
||
//Critical fix: Use direct blend mode (normal overlay, no color blending)
|
||
img.compositeImage(
|
||
canvas,
|
||
scaledImage,
|
||
dstX: 0,
|
||
dstY: 0,
|
||
blend: img.BlendMode.direct,
|
||
);
|
||
processedImage = canvas;
|
||
}
|
||
}
|
||
|
||
//3. Handle memory size compression (JPEG quality adjustment)
|
||
if (params.memorySize == null) {
|
||
//No memory limit, return 100% quality JPEG
|
||
return img.encodeJpg(processedImage, quality: 100);
|
||
}
|
||
|
||
//Calculate max bytes (MB → Bytes)
|
||
final maxBytes = (params.memorySize! * 1024 * 1024).toInt();
|
||
int quality = 100; //Corresponds to iOS compressionQuality=1.0
|
||
//Null safety fix: encodeJpg may return null, need ? and handling
|
||
Uint8List compressedData = img.encodeJpg(processedImage, quality: quality);
|
||
//Gradually reduce quality (×0.7 each time) until size limit met or quality below 1%
|
||
while (compressedData.length > maxBytes && quality > 1) {
|
||
quality = (quality * 0.7).round();
|
||
if (quality < 1) quality = 1; //Minimum quality limit is 1%
|
||
compressedData = img.encodeJpg(processedImage, quality: quality);
|
||
}
|
||
|
||
return compressedData;
|
||
} catch (e) {
|
||
debugPrint('图片压缩Failed:$e');
|
||
return null;
|
||
}
|
||
}
|