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 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 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 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? _decodeToRaw(Map 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 _rawToMp4( String rawPath, Map 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 _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 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 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; } }