水印相机前端

This commit is contained in:
2026-09-28 09:59:43 +08:00
parent 187a7e0911
commit c5b3d81d07
19 changed files with 2761 additions and 3 deletions
+370
View File
@@ -0,0 +1,370 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../../../models/watermark.dart';
/// 水印绘制核心 —— **预览、拖动、导出三处共用同一套几何**。
///
/// 关键约定:所有尺寸都按 `scale = 画布宽度 / [kWatermarkDesignWidth]` 等比缩放。
/// 于是同一个 [paintWatermark],在预览时传屏幕显示尺寸、在导出时传图片像素尺寸,
/// 出来的**视觉完全一致**——这就是「所见即所得」的实现方式。
///
/// 改样式(字号/间距/颜色/位置)只改这里,三处自动同步。
/// 设计稿基准宽度(沿用原水印相机项目的 1080)
const double kWatermarkDesignWidth = 1080;
// ---- 设计稿尺寸(单位:设计稿像素,实际绘制时乘 scale)----
const double _margin = 28; // 水印块到画布边缘的默认距离
const double _lineWidth = 4; // 左侧黄色竖线宽
const double _lineToText = 14; // 竖线到文字的水平距离
const double _cardPaddingY = 16; // 水印块上下内边距
const double _fontSize = 37;
const double _rowHeight = 54;
const double _rowGap = 8;
const int _maxCharsPerLine = 18; // 每行最多几个字,超了折行
// 右下角品牌角标
const double _brandMargin = 8;
const double _brandIconSize = 48;
const double _brandTextSize = 32;
const double _brandGap = 10;
const double _brandPaddingX = 16;
const double _brandPaddingY = 12;
const double _brandOpacity = 0.8;
const String _brandText = '水印相机';
/// 黄色竖线的颜色(品牌装饰,不跟随文字颜色变)
const Color _accentColor = Color(0xFFFFB800);
/// 水印正文块的几何信息。
///
/// 抽出来是为了让「拖动」能用和「绘制」完全一样的公式算位置 ——
/// 两边各算一遍的话,迟早会漂移。
class WatermarkLayout {
const WatermarkLayout({
required this.scale,
required this.lines,
required this.textHeight,
required this.cardHeight,
required this.cardWidth,
required this.origin,
});
final double scale;
/// 折行后的实际文本行(已按 [_maxCharsPerLine] 拆好)
final List<String> lines;
/// 所有文本行的总高度
final double textHeight;
/// 块总高(含上下内边距);没有内容时为 0
final double cardHeight;
/// 块总宽(最宽一行 + 竖线 + 间距)
final double cardWidth;
/// 块左上角在画布上的位置
final Offset origin;
bool get isEmpty => lines.isEmpty;
}
/// 按 [size] 算出水印块的位置和尺寸
WatermarkLayout layoutWatermark(
Size size,
WatermarkSettings settings,
WatermarkData data,
) {
final scale = size.width / kWatermarkDesignWidth;
final lines = <String>[
for (final line in buildWatermarkLines(settings, data))
// 标了「不折行」的(时间)原样保留,其余按字数折
...(line.wrap ? _wrapByCharCount(line.text, _maxCharsPerLine) : [line.text]),
];
if (lines.isEmpty) {
return WatermarkLayout(
scale: scale,
lines: const [],
textHeight: 0,
cardHeight: 0,
cardWidth: 0,
origin: Offset(
settings.posX != null ? settings.posX! * size.width : _margin * scale,
settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale,
),
);
}
final rowH = _rowHeight * scale;
final gap = _rowGap * scale;
final padding = _cardPaddingY * scale;
final textHeight = lines.length * rowH + (lines.length - 1) * gap;
final cardHeight = textHeight + padding * 2;
// 最宽一行决定块的宽度(拖动时用它做边界夹紧)
var maxLineWidth = 0.0;
for (final line in lines) {
final painter = _layoutText(
line,
fontSize: _fontSize * scale,
color: Colors.white,
scale: scale,
);
if (painter.width > maxLineWidth) maxLineWidth = painter.width;
}
final cardWidth = _lineWidth * scale + _lineToText * scale + maxLineWidth;
final origin = Offset(
settings.posX != null ? settings.posX! * size.width : _margin * scale,
settings.posY != null
? settings.posY! * size.height
: size.height - _margin * scale - cardHeight,
);
return WatermarkLayout(
scale: scale,
lines: lines,
textHeight: textHeight,
cardHeight: cardHeight,
cardWidth: cardWidth,
origin: origin,
);
}
/// 画一笔水印。
///
/// [size] 既可以是屏幕显示尺寸(预览),也可以是图片像素尺寸(导出)。
void paintWatermark(
Canvas canvas,
Size size,
WatermarkSettings settings,
WatermarkData data, {
ui.Image? brandIcon,
}) {
if (size.width <= 0 || size.height <= 0) return;
final layout = layoutWatermark(size, settings, data);
final scale = layout.scale;
final textColor = Color(settings.colorValue);
// ---- 正文块 ----
if (!layout.isEmpty) {
final origin = layout.origin;
final padding = _cardPaddingY * scale;
// 左侧黄色竖线
canvas.drawRect(
Rect.fromLTWH(origin.dx, origin.dy + padding, _lineWidth * scale, layout.textHeight),
Paint()..color = _accentColor,
);
// 多行文字
final textX = origin.dx + _lineWidth * scale + _lineToText * scale;
var y = origin.dy + padding;
for (final line in layout.lines) {
final painter = _layoutText(
line,
fontSize: _fontSize * scale,
color: textColor,
scale: scale,
);
painter.paint(canvas, Offset(textX, y));
y += _rowHeight * scale + _rowGap * scale;
}
}
// ---- 右下角品牌角标 ----
if (settings.showBrand) {
_paintBrand(canvas, size, scale, brandIcon);
}
}
/// 右下角「相机图标 + 水印相机」,整体 80% 透明
void _paintBrand(Canvas canvas, Size size, double scale, ui.Image? brandIcon) {
final margin = _brandMargin * scale;
final iconSize = _brandIconSize * scale;
final gap = _brandGap * scale;
final padX = _brandPaddingX * scale;
final padY = _brandPaddingY * scale;
final textPainter = _layoutText(
_brandText,
fontSize: _brandTextSize * scale,
color: Colors.white,
bold: true,
scale: scale,
);
final hasIcon = brandIcon != null;
final brandW = (hasIcon ? iconSize + gap : 0) + textPainter.width + padX * 2;
final brandH = (hasIcon ? iconSize : textPainter.height) + padY * 2;
final brandX = size.width - margin - brandW;
final brandY = size.height - margin - brandH;
// saveLayer 的 paint alpha 就是整组元素的透明度(Flutter 的 Opacity 内部也这么做)
canvas.saveLayer(
Rect.fromLTWH(brandX, brandY, brandW, brandH),
Paint()..color = const Color(0xFF000000).withValues(alpha: _brandOpacity),
);
double contentX = brandX + padX;
if (hasIcon) {
canvas.drawImageRect(
brandIcon,
Rect.fromLTWH(0, 0, brandIcon.width.toDouble(), brandIcon.height.toDouble()),
Rect.fromLTWH(contentX, brandY + padY, iconSize, iconSize),
Paint()..filterQuality = FilterQuality.medium,
);
contentX += iconSize + gap;
}
// 文字在内容区里垂直居中
final contentCenterY = brandY + brandH / 2;
textPainter.paint(canvas, Offset(contentX, contentCenterY - textPainter.height / 2));
canvas.restore();
}
/// 排一段文字。加了淡阴影 —— 白字直接压在浅色照片上会糊,老项目没做这一步。
TextPainter _layoutText(
String text, {
required double fontSize,
required Color color,
required double scale,
bool bold = false,
}) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
color: color,
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
height: 1.2,
shadows: [
Shadow(
color: const Color(0x99000000),
blurRadius: 5 * scale,
offset: Offset(0, 1 * scale),
),
],
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
);
painter.layout();
return painter;
}
/// 按字符数折行(中文按字数折最直观,和原项目一致)
List<String> _wrapByCharCount(String text, int maxChars) {
if (text.length <= maxChars) return [text];
final result = <String>[];
for (var i = 0; i < text.length; i += maxChars) {
final end = (i + maxChars < text.length) ? i + maxChars : text.length;
result.add(text.substring(i, end));
}
return result;
}
/// 预览用:把水印盖在照片上
class WatermarkPainter extends CustomPainter {
const WatermarkPainter({
required this.settings,
required this.data,
this.brandIcon,
});
final WatermarkSettings settings;
final WatermarkData data;
final ui.Image? brandIcon;
@override
void paint(Canvas canvas, Size size) {
paintWatermark(canvas, size, settings, data, brandIcon: brandIcon);
}
@override
bool shouldRepaint(WatermarkPainter old) {
return old.settings != settings ||
old.data != data ||
old.brandIcon != brandIcon;
}
}
/// 把原图和水印合成为一张 JPEG。
///
/// 走离屏绘制而不是「截图 Widget」:这样能按**图片原始像素**出图,
/// 不受屏幕分辨率限制,也不会因为页面滚动/遮挡出问题。
///
/// [maxWidth] 限制最长边(原项目也是 1920)—— 一是省内存,二是纯 Dart 的
/// JPEG 编码在大图上会明显变慢。
Future<Uint8List> composeWatermarkedJpeg({
required Uint8List originalBytes,
required WatermarkSettings settings,
required WatermarkData data,
ui.Image? brandIcon,
double maxWidth = 1920,
int quality = 92,
}) async {
// 先只读文件头拿原始尺寸,再决定按多大解码。
// 直接全尺寸解码一张 12MP 照片要 48MB 内存,没必要。
final buffer = await ui.ImmutableBuffer.fromUint8List(originalBytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
final ratio = descriptor.width > maxWidth ? maxWidth / descriptor.width : 1.0;
final outW = (descriptor.width * ratio).round();
final outH = (descriptor.height * ratio).round();
final codec = await descriptor.instantiateCodec(targetWidth: outW, targetHeight: outH);
final frame = await codec.getNextFrame();
final source = frame.image;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImage(source, Offset.zero, Paint());
paintWatermark(
canvas,
Size(outW.toDouble(), outH.toDouble()),
settings,
data,
brandIcon: brandIcon,
);
final picture = recorder.endRecording();
final rendered = await picture.toImage(outW, outH);
// 到这里内容已经栅格化,可以把中间对象都放掉
source.dispose();
picture.dispose();
codec.dispose();
descriptor.dispose();
buffer.dispose();
final byteData = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
rendered.dispose();
if (byteData == null) {
throw StateError('水印合成失败:拿不到位图数据');
}
// toByteData 只有 PNG / rawRgba,出不了 JPEG,所以自己用 image 包编码。
// 之所以非要 JPEG:1920×1440 的照片 PNG 要 3~6MB,JPEG 只要 ~500KB。
// 画面不透明(底图铺满),预乘与非预乘 RGBA 等价,直接当 RGBA 用。
final raster = img.Image.fromBytes(
width: outW,
height: outH,
bytes: byteData.buffer,
bytesOffset: byteData.offsetInBytes,
numChannels: 4,
);
return img.encodeJpg(raster, quality: quality);
}