初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
import 'dart:io';
import 'dart:ui';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide Image;
import 'package:flutter/rendering.dart';
import 'package:flutter_native_image/flutter_native_image.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:image/image.dart' as img;
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:zxing2/qrcode.dart';
class ImageUtil {
/// 从图片文件解析二维码(纯 Dart zxing,替代 ML Kit)
/// iOS 相册多为 HEIC,image 包不支持→decodeImage 返回 null,先用原生统一转 jpg
/// (顺带按 EXIF 摆正方向);转换走主 isolate(插件不能在 compute 里调),解码再丢 isolate
static Future<String?> decodeQr(String path) async {
var decodePath = path;
try {
final converted = await FlutterNativeImage.compressImage(path,
percentage: 100, quality: 100);
decodePath = converted.path;
} catch (_) {
// 转换失败就拿原图碰运气(本就是 jpg/png 时不影响)
}
return compute(_decodeQrSync, decodePath);
}
/// isolate 解码入口(必须是 top-level/static 才能传给 compute)
static String? _decodeQrSync(String path) {
try {
final image = img.decodeImage(File(path).readAsBytesSync());
if (image == null) return null;
final source = RGBLuminanceSource(
image.width,
image.height,
image
.convert(numChannels: 4)
.getBytes(order: img.ChannelOrder.rgba)
.buffer
.asInt32List(),
);
final bitmap = BinaryBitmap(HybridBinarizer(source));
// tryHarder:相册照片有边框/轻微旋转/噪点,放开更费时但识别率更高
final hints = DecodeHints()..put(DecodeHintType.tryHarder);
return QRCodeReader().decode(bitmap, hints: hints).text;
} catch (_) {
return null;
}
}
/// 截取 [boundaryKey] 对应的 RepaintBoundary 存相册
/// 注:boundary 必须完整渲染在屏幕内,被滚动容器裁剪会截不全甚至失败
static Future<bool> saveWidgetToAlbum(GlobalKey boundaryKey) async {
await WidgetsBinding.instance.endOfFrame;
final boundary = boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return false;
Image? image;
try {
image = await boundary.toImage(pixelRatio: 3.0);
final pngBytes = await image.toByteData(format: ImageByteFormat.png);
if (pngBytes == null) return false;
return await savePngToAlbum(pngBytes.buffer.asUint8List());
} catch (e) {
debugLog('saveWidgetToAlbum 截图失败', e);
return false;
} finally {
// ui.Image 持有 native 内存,必须手动释放,否则每次截图都泄漏
image?.dispose();
}
}
/// 保存图片到相册(自动识别动图)
///
/// 动图转成 mp4 再存,另外几条路都堵死了:
/// - 不能走 saveImageAndroid 是 BitmapFactory、iOS 是 UIImage,都只取第一帧
/// - 不能原样存 webpMediaStore 收得下 image/webp,但绝大多数相册 App
/// (含 Google Photos)只把 animated webp 当静态图渲染,看着还是一帧
/// - 不用 GIF:纯 Dart 编码是每帧重建量化器 + LZW,又慢又只有 256 色(发糊起色带)
static Future<bool> saveImageToAlbum(Uint8List? bytes) async {
if (bytes == null) return false;
final tempDir = await getTemporaryDirectory();
final stamp = DateTime.now().millisecondsSinceEpoch;
final rawPath = '${tempDir.path}/anim_$stamp.rgba';
// 解码+合成放 isolate,且直接把 RGBA 写盘——十几兆的像素数组不跨 isolate 拷回来
final meta = await compute(_decodeToRaw, {'bytes': bytes, 'path': rawPath});
if (meta == null) return savePngToAlbum(bytes); //静态图/解不动,走原路径
try {
final mp4 = await _rawToMp4(rawPath, meta);
if (mp4 != null) return _saveRawToAlbum(mp4, 'mp4');
return savePngToAlbum(bytes); //ffmpeg 失败:退化成存首帧,日志里有原因
} finally {
final raw = File(rawPath);
if (await raw.exists()) await raw.delete();
}
}
/// isolate 入口:动图 → 合成好的 RGBA 裸流写入 [path],返回宽高/帧数/帧间隔;静态图返回 null
///
/// 走 raw 而不是让 ffmpeg 直接 `-i xxx.webp`FFmpeg 到 7.1 才支持解 animated WebP
/// 这个 fork 的版本不确定,赌不起。裸流写盘只是 memcpy,成本可以忽略。
static Map<String, int>? _decodeToRaw(Map<String, dynamic> args) {
const maxFrames = 24;
const maxSide = 640; //x264 编码便宜,可以给到比 GIF 时期高的分辨率
final bytes = args['bytes'] as Uint8List;
try {
final decoder = img.findDecoderForData(bytes);
if (decoder == null) return null;
decoder.startDecode(bytes); //只解头/块信息,不解像素,很便宜
final total = decoder.numFrames();
debugLog('saveToAlbum',
'${decoder.runtimeType} frames=$total size=${bytes.length}');
if (total <= 1) return null;
// 必须整体 decodeanimated webp 的每帧只是个局部矩形,靠 dispose/blend 往画布上叠,
// 逐帧 decodeFrame 拿到的是没合成的碎片(帧时长也只有 decode 里才写)
final src = decoder.decode(bytes);
if (src == null || !src.hasAnimation) return null;
final step = (src.numFrames / maxFrames).ceil();
final longest = src.width > src.height ? src.width : src.height;
final scale = longest > maxSide ? maxSide / longest : 1.0;
//H.264 的 yuv420p 要求宽高都是偶数,这里直接对齐掉
final outW = ((src.width * scale).round() ~/ 2) * 2;
final outH = ((src.height * scale).round() ~/ 2) * 2;
if (outW <= 0 || outH <= 0) return null;
final sink = File(args['path'] as String).openSync(mode: FileMode.write);
var count = 0;
var delay = 0;
for (var i = 0; i < src.numFrames; i += step) {
//frames[0] 就是 src 本身、挂着整条动画,copyResize 会连整条一起缩放,得先取单帧副本
final f = i == 0
? img.Image.from(src.frames[0], noAnimation: true)
: src.frames[i];
//必须指定 averagecopyResize 默认是 nearest,降采样直接丢像素,出来全是锯齿
final out = (f.width == outW && f.height == outH)
? f
: img.copyResize(f,
width: outW,
height: outH,
interpolation: img.Interpolation.average);
sink.writeFromSync(out.getBytes(order: img.ChannelOrder.rgba));
if (delay == 0 && f.frameDuration > 0) delay = f.frameDuration;
count++;
}
sink.closeSync();
if (count < 2) return null;
debugLog('saveToAlbum',
'raw ${outW}x$outH frames=$count step=$step delay=$delay');
//抽帧后帧间隔要乘上 step,整体播放速度才不变;源没写时长就兜 80ms
return {
'w': outW,
'h': outH,
'n': count,
'delay': (delay > 0 ? delay : 80) * step
};
} catch (e) {
debugLog('saveToAlbum', 'decodeToRaw failed: $e');
return null;
}
}
/// RGBA 裸流 → H.264 mp4x264 在 -gpl 变体里才有)
static Future<Uint8List?> _rawToMp4(
String rawPath, Map<String, int> meta) async {
final outPath = rawPath.replaceFirst(RegExp(r'\.rgba$'), '.mp4');
final fps = (1000 / (meta['delay'] ?? 80)).clamp(1.0, 60.0);
final cmd =
'-y -f rawvideo -pixel_format rgba -video_size ${meta['w']}x${meta['h']} '
'-framerate ${fps.toStringAsFixed(2)} -i "$rawPath" '
'-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart "$outPath"';
final out = File(outPath);
try {
final session = await FFmpegKit.execute(cmd);
if (!ReturnCode.isSuccess(await session.getReturnCode())) {
debugLog('saveToAlbum',
'ffmpeg failed: ${await session.getAllLogsAsString()}');
return null;
}
return await out.readAsBytes();
} catch (e) {
debugLog('saveToAlbum', 'rawToMp4 failed: $e');
return null;
} finally {
if (await out.exists()) await out.delete();
}
}
/// 按原文件字节写进相册(不重编码)。[ext] 决定 MIME:mp4 落视频集合,图片落图片集合
static Future<bool> _saveRawToAlbum(Uint8List bytes, String ext) async {
if (!await requestAlbumPermission()) {
showToast("请先开启相册权限");
return false;
}
final fileName = 'hgdj_${DateTime.now().millisecondsSinceEpoch}.$ext';
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(bytes, flush: true);
try {
// iOS 插件对图片和视频的路径要求正好相反,传错就存不进去:
// - 视频走 UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(path) 判定,只吃裸文件路径;
// 再用 isReturnPathOfIOS:false 的 UISaveVideoAtPathToSavedPhotosAlbum,回调稳定
// - 图片必须 isReturnPathOfIOS:true 才走 PHAsset 原文件写入(false 分支是 UIImage,动图丢帧)
// 而那条分支用 URL(string:) 解析,裸路径没 scheme 会被 PhotoKit 拒,得传 file:// 形式
final isVideo = ext == 'mp4';
final result = await ImageGallerySaver.saveFile(
(Platform.isIOS && !isVideo)
? Uri.file(file.path).toString()
: file.path,
name: fileName,
isReturnPathOfIOS: !isVideo,
).timeout(const Duration(seconds: 20),
onTimeout: () => null); //插件在拿不到 fullSizeImageURL 时不回调,兜底防卡死
return result is Map && result["isSuccess"] == true;
} catch (e) {
debugLog('saveRawToAlbum 写入相册失败', e);
return false;
} finally {
if (await file.exists()) {
await file.delete();
}
}
}
/// 保存 png 数据到相册(静态图/截图用;动图请走 [saveImageToAlbum]
static Future<bool> savePngToAlbum(Uint8List? pngBytes) async {
if (pngBytes == null) return false;
final hasPermission = await requestAlbumPermission();
if (!hasPermission) {
showToast("请先开启相册权限");
return false;
}
final fileName = 'hgdj_${DateTime.now().millisecondsSinceEpoch}.png';
dynamic result;
if (Platform.isIOS) {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(pngBytes, flush: true);
try {
result = await ImageGallerySaver.saveFile(file.path, name: fileName);
} catch (e) {
debugLog('savePngToAlbum 写入相册失败', e);
return false;
} finally {
if (await file.exists()) {
await file.delete();
}
}
} else {
result = await ImageGallerySaver.saveImage(
pngBytes,
quality: 100,
name: fileName,
);
}
return result is Map && result["isSuccess"] == true;
}
/// 相册写入权限(存视频也走这里,见 VideoSaveUtil
static Future<bool> requestAlbumPermission() async {
if (Platform.isIOS || Platform.isMacOS) {
final addOnlyStatus = await Permission.photosAddOnly.request();
if (addOnlyStatus.isGranted || addOnlyStatus.isLimited) {
return true;
}
final status = await Permission.photos.request();
return status.isGranted || status.isLimited;
}
if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
final sdkInt = deviceInfo.version.sdkInt;
// Android 10+(API 29) 通过 MediaStore 保存,不需要额外权限
if (sdkInt >= 29) return true;
// Android 9 及以下需要存储写入权限
final status = await Permission.storage.request();
return status.isGranted;
}
return false;
}
}