Files
2026-09-15 15:44:13 +07:00

301 lines
12 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}