Files
photo-live/apps/mobile/lib/pages/image_crop_page.dart
T
浅念 683e19bdc1 feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)
review fixes: drop GIF from pre-sniff (package maps gif→png decode → never-ready hang); PopScope blocks back during processing

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 16:01:24 +08:00

308 lines
9.3 KiB
Dart
Raw 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.
/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → JPEG 落盘。
///
/// 输入 image_picker 选中的本机路径;输出裁剪后的临时文件路径
/// (`{tmp}/album_assets/crop_{ms}.jpg`);取消/失败返回 null。
/// 封面 16:9(输出宽 ≤1920)、宣传图 3:1(输出宽 ≤2400)由调用方给定。
///
/// crop_your_image 2.0.0 为回调式 API:`CropController.crop()` 是 void,
/// 裁剪结果经 `onCropped` 回调(`CropSuccess.croppedImage` /
/// `CropFailure.cause`)。像素级 decode/缩放/编码统一放
/// `Isolate.run`,避免大图卡死主 isolate。
library;
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:crop_your_image/crop_your_image.dart';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../l10n/app_localizations.dart';
import '../ui/tokens.dart';
/// 裁剪页输出目录(应用临时目录下的 album_assets;测试直接可用真实 FS)。
Directory _cropOutputDir() {
final dir = Directory(
'${Directory.systemTemp.path}${Platform.pathSeparator}album_assets',
);
dir.createSync(recursive: true);
return dir;
}
/// 裁剪字节 → 可解码格式预检(魔数嗅探;HEIC/GIF 等走不支持提示)。
///
/// 包内解析失败(`_parseImageWith` 的 compute 抛错)不会回调页面——
/// 页面在交给 [Crop] 前先嗅探,拦下注定解析失败的源。
/// GIF 明确不支持:包内 formatDetector 的兜底分支按 png 解码
/// (`_ => ImageFormat.png`),decodePng(gif) 必然失败 → onStatusChanged
/// 永不 ready → 页面挂死在加载态。
bool _hasSupportedSignature(Uint8List bytes) {
if (bytes.length < 12) {
return false;
}
final jpeg = bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
final png =
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47;
final bmp = bytes[0] == 0x42 && bytes[1] == 0x4D;
final webp =
bytes[0] == 0x52 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x46 &&
bytes[8] == 0x57 &&
bytes[9] == 0x45 &&
bytes[10] == 0x42 &&
bytes[11] == 0x50;
return jpeg || png || bmp || webp;
}
/// 裁剪输出编码 + 落盘(只在 isolate 内执行)。
String _encodeCropToJpeg(Uint8List cropped, int? maxOutputWidth) {
final decoded = img.decodeImage(cropped);
if (decoded == null) {
throw img.ImageException('crop output is not decodable');
}
var output = decoded;
if (maxOutputWidth != null && decoded.width > maxOutputWidth) {
output = img.copyResize(decoded, width: maxOutputWidth);
}
final file = File(
'${_cropOutputDir().path}${Platform.pathSeparator}'
'crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
file.writeAsBytesSync(img.encodeJpg(output, quality: 90));
return file.path;
}
/// 封面/宣传图裁剪页。
class ImageCropPage extends StatefulWidget {
const ImageCropPage({
super.key,
required this.sourcePath,
required this.aspectRatio,
this.maxOutputWidth,
});
/// 源图本机路径(image_picker 单选返回)。
final String sourcePath;
/// 目标宽高比(宽/高):封面 16/9、宣传图 3/1。
final double aspectRatio;
/// 输出宽度上限(等比缩放;null=保持裁剪原分辨率)。
final int? maxOutputWidth;
@override
State<ImageCropPage> createState() => _ImageCropPageState();
}
class _ImageCropPageState extends State<ImageCropPage> {
final CropController _controller = CropController();
late final Future<Uint8List> _sourceBytes;
/// 源读取完成且魔数预检通过(可裁剪)。
bool _ready = false;
/// 包内解析完成(CropStatus.ready;完成按钮需等解析完再开放)。
bool _cropReady = false;
/// 源读取失败或预检不通过(HEIC 等不支持的格式)。
bool _unsupported = false;
/// 编码落盘中(遮罩 + 防重复点击)。
bool _processing = false;
@override
void initState() {
super.initState();
_sourceBytes = File(widget.sourcePath).readAsBytes();
_sourceBytes
.then((bytes) {
if (!mounted) {
return;
}
setState(() {
_ready = _hasSupportedSignature(bytes);
_unsupported = !_ready;
});
})
.catchError((Object _) {
if (!mounted) {
return;
}
setState(() => _unsupported = true);
});
}
void _cancel() {
Navigator.of(context).pop(null);
}
void _confirm() {
if (_processing || !_ready || !_cropReady) {
return;
}
setState(() => _processing = true);
// crop() 为 void:结果经 onCropped 回调。
_controller.crop();
}
/// 包内状态回调:解析完成(ready)才允许确认,防 crop() 在解析前触发的断言。
void _onCropStatus(CropStatus status) {
final ready = status == CropStatus.ready;
if (ready == _cropReady) {
return;
}
setState(() => _cropReady = ready);
}
Future<void> _onCropped(CropResult result) async {
if (!mounted) {
return;
}
if (result is! CropSuccess) {
_fail();
return;
}
// 仅捕获可发送值(Uint8List/int),闭包跨 isolate 复制。
final cropped = result.croppedImage;
final maxWidth = widget.maxOutputWidth;
try {
final outputPath = await Isolate.run(
() => _encodeCropToJpeg(cropped, maxWidth),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(outputPath);
} on Object {
if (!mounted) {
return;
}
_fail();
}
}
/// 裁剪/编码失败:解除遮罩 + 提示,不 pop。
void _fail() {
setState(() => _processing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).cropUnsupportedFormat),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// 编码落盘中拦截系统返回/ AppBar 返回(与底部取消禁用一致)。
return PopScope(
canPop: !_processing,
child: Scaffold(
appBar: AppBar(title: Text(l10n.cropTitle)),
body: Stack(
children: [
Positioned.fill(child: _buildBody(l10n)),
if (_processing)
Positioned.fill(
child: AbsorbPointer(
child: ColoredBox(
color: Colors.black54,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: kSpace12),
Text(
l10n.cropProcessing,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
),
),
),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace12,
),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: _processing ? null : _cancel,
child: Text(l10n.cropCancel),
),
),
const SizedBox(width: kSpace12),
Expanded(
child: FilledButton(
onPressed: (_ready && _cropReady && !_processing)
? _confirm
: null,
child: Text(l10n.cropConfirm),
),
),
],
),
),
),
),
);
}
Widget _buildBody(AppLocalizations l10n) {
if (_unsupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace16),
child: Text(
l10n.cropUnsupportedFormat,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return FutureBuilder<Uint8List>(
future: _sourceBytes,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done ||
!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return Crop(
image: snapshot.data!,
controller: _controller,
onCropped: _onCropped,
onStatusChanged: _onCropStatus,
aspectRatio: widget.aspectRatio,
// 交互模式 + 锁定裁剪框:用户平移/缩放图片适配固定比例框。
interactive: true,
fixCropRect: true,
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
progressIndicator: const CircularProgressIndicator(strokeWidth: 2),
);
},
);
}
}