初始化
This commit is contained in:
@@ -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 再存,另外几条路都堵死了:
|
||||
/// - 不能走 saveImage:Android 是 BitmapFactory、iOS 是 UIImage,都只取第一帧
|
||||
/// - 不能原样存 webp:MediaStore 收得下 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;
|
||||
|
||||
// 必须整体 decode:animated 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];
|
||||
//必须指定 average:copyResize 默认是 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 mp4(x264 在 -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user