水印跨底图自适应研发需求文档
文档状态:待研发评审
适用范围:图片水印的预览、配置保存、后台合成与跨比例重算
配套 Demo:https://ai-photo-watermark-geometry-demo.pages.dev/;浏览器阅读版:https://ai-photo-watermark-geometry-demo.pages.dev/watermark-requirements
核心原则:用户参数是唯一输入,切换底图只生成派生渲染几何,不得反向改写用户值。
1. 背景、目标与范围
1.1 背景
用户可能在 1080P 示例图上设置水印,但项目内实际图片可能是 1K、4K、8K 甚至更高分辨率,并同时包含横图、竖图和方图。若直接保存示例图上的像素尺寸和像素坐标,会导致水印在高分辨率图片上过小,且在不同长宽比图片上位置漂移。
本文档定义一套与像素分辨率无关、可跨端复现的水印几何规则。
1.2 产品目标
本期优化的核心目标是:用户只设置一次水印样式,同一组参数应用到不同分辨率、不同横竖比例的底图时,水印的视觉大小、位置关系和整体构图意图应基本一致,不应因为底图比例或像素尺寸变化而出现不可预测的放大、缩小或位置漂移。
- 用户只设置一组水印参数,可应用到项目内所有图片。
- 相同长宽比的 1K、4K、8K 图片,水印的归一化大小和位置一致。
- 角标水印在横图、竖图和方图上保持对应的语义锚点,以及距离最近画面边缘的相对边距。
- 大面积水印仍使用同一套大小与位置规则,不自动进入另一种模式,不根据覆盖率二次放大。
- 水印允许超出画布,超出部分在最终合成时裁剪;不得为了留在画布内而自动缩小或吸附。
- 预览与最终导出使用同一套几何规则,结果可验证、可回归。
- 水印配置多次修改、异步重算和失败重试时,不得叠加旧水印或用旧任务覆盖新结果。
1.3 本期范围
用户只设置一组项目全局参数:
sizeValue:大小,0~100
positionX:水平位置,-100~100
positionY:垂直位置,-100~100
opacity:不透明度,0~100
同一组参数应用到 9:16、16:9、2:3、3:2、3:4、4:3、1:1 及不同分辨率底图时,必须满足:
- 切换底图不得改写
sizeValue、positionX、positionY、opacity。 - 水印完整源文件的最长边,始终按目标底图最长边的固定比例换算。
- 边缘水印保持短边归一化语义边距,中轴水印保持用户位置值对应的归一化中心。
sizeValue = 0或opacity = 0时,水印不可见。- 从任意底图切回原底图时,水印的尺寸和位置必须精确恢复,不得累积偏移。
本期只有一套统一水印换算规则,不进行铺满判断,不支持旋转。
本逻辑不改动既有产品默认值:
| 参数 | 范围 | 产品默认值 |
|---|---|---|
大小 sizeValue |
0~100,整数 | 5 |
水平位置 positionX |
-100~100 | 10 |
垂直位置 positionY |
-100~100 | 7 |
不透明度 opacity |
0~100 | 100 |
Demo 中的“重置”是用于验证右上角边距的测试预设,不代表产品默认值。
1.4 非本期范围
- 文字水印、平铺重复水印和多水印图层。
- 水印旋转、透视变换、非等比拉伸。
- 自动铺满、手动选择“适应 / 填满”模式、多比例水印素材自动匹配。
- 动态图水印、视频水印。
2. 基础定义
W、H:目标底图最终输出宽高
R = min(W, H):目标底图短边,用于位置边距归一化
L = max(W, H):目标底图长边,用于水印大小归一化
sourceW、sourceH:完整水印源文件宽高,包含 PNG 透明区域
contentX、contentY、contentW、contentH:水印有效内容包围盒
PNG 有效内容定义为 Alpha >= 5% 的全部像素形成的最小轴对齐矩形。低于阈值的透明像素不参与大小、位置、锚点和覆盖率计算,但渲染时仍保留完整源图。
JPG、JPEG 无透明通道,有效内容包围盒等于完整源文件。
3. 总体判断流程
3.1 全链路总览
flowchart TD
A["用户编辑水印"] --> B["校验素材与参数"]
B --> C["在参考底图生成锚点配置"]
C --> D["保存用户值、profile 和版本号"]
D --> E["为每张目标图计算统一几何"]
E --> F["从无水印母版合成并裁剪"]
F --> G["版本复核后原子替换发布图"]
B -->|"校验失败"| B1["保留旧素材、旧配置和旧发布图"]
E -->|"任务过期"| E1["丢弃旧任务输出"]
F -->|"单图失败"| F1["保留该图旧发布版本并支持重试"]
3.2 参考配置生成详细流程
flowchart TD
A["进入设置、修改参数、拖动结束或更换素材"] --> B{"水印开关是否开启"}
B -->|"否"| B1["保存关闭状态"]
B -->|"是"| C["读取水印素材"]
C --> D["校验格式、文件大小、魔数和解码像素量"]
D --> E{"素材是否合法"}
E -->|"否"| E1["提示错误;保留旧素材和旧配置"]
E -->|"是"| F["按 EXIF 方向归一化水印素材"]
F --> G{"是否存在 Alpha >= 5% 的像素"}
G -->|"否"| G1["判定无有效内容;拒绝使用"]
G -->|"是"| H["提取 source 尺寸和 content 包围盒"]
H --> I["校验 size、position、opacity 范围"]
I --> J{"参数是否合法"}
J -->|"否"| J1["UI 取最近边界;服务端拒绝非法值"]
J -->|"是"| K["读取参考底图显示方向后的 W、H"]
K --> L["将 position -100~100 映射为有效内容中心"]
L --> M{"size = 0 或 opacity = 0"}
M -->|"是"| M1["预览不展示;使用零尺寸中心继续推断锚点"]
M -->|"否"| N["按底图长边计算完整文件 scale"]
N --> O["计算有效内容框和完整源文件框"]
M1 --> P["计算九个候选锚点距离"]
O --> P
P --> Q{"最近锚点是否唯一"}
Q -->|"是"| R["选取最近锚点"]
Q -->|"否"| S{"是否存在上一次锚点"}
S -->|"是"| S1["沿用上一次锚点"]
S -->|"否"| S2["按固定优先级选择;中心优先"]
R --> T["按参考底图短边计算 offsetX/YRatio"]
S1 --> T
S2 --> T
T --> U["保存 userSettings、assetMeta、profile"]
U --> V["configVersion + 1;触发目标图任务"]
3.3 单张目标图应用详细流程
flowchart TD
A["收到 imageId + configVersion 合成任务"] --> B["读取无水印母版和目标底图元数据"]
B --> C["按 EXIF 方向归一化目标底图"]
C --> D{"尺寸、素材哈希和配置版本是否合法"}
D -->|"否"| D1["单图失败;保留旧发布图并记录错误"]
D -->|"是"| E{"任务 configVersion 是否仍为最新"}
E -->|"否"| E1["丢弃过期任务输出"]
E -->|"是"| F{"开关关闭、size = 0 或 opacity = 0"}
F -->|"是"| F1["使用无水印母版作为候选输出"]
F -->|"否"| G["按目标底图长边重新计算 scale"]
G --> H["横轴独立换算目标锚点位置"]
H --> I["纵轴独立换算目标锚点位置"]
I --> J["计算目标有效内容框"]
J --> K["由 content 偏移反推完整源文件框"]
K --> L{"水印与目标画布是否相交"}
L -->|"否"| L1["使用无水印母版作为候选输出;不吸附"]
L -->|"是"| M["素材 Alpha 乘以全局 opacity"]
M --> N["从无水印母版开始合成"]
N --> O["裁剪到目标 W x H"]
O --> P["编码图片并应用统一色彩策略"]
F1 --> Q{"替换前 configVersion 是否仍为最新"}
L1 --> Q
P --> Q
Q -->|"否"| E1
Q -->|"是"| R["原子替换发布图"]
R --> S["逐张刷新项目页、文件夹和 H5 展示"]
流程中的所有判断都不得产生“普通 / 铺满”分类。覆盖率只允许作为调试、验收和监控指标,不得参与目标图缩放或反向修改用户参数。
“切换底图”只执行目标图几何换算,不是新的用户编辑操作,因此不得重新生成参考配置,不得回写任何参数。
4. 用户原始大小规则
用户原始大小统一以“完整水印源文件最长边占底图最长边的比例”计算:
sizeRatio = sizeValue * 1.5%
canvasLongSide = max(W, H)
sourceLongSide = max(sourceW, sourceH)
requestedSourceLongSide = canvasLongSide * sizeRatio
requestedScale = requestedSourceLongSide / sourceLongSide
边界含义:
sizeValue = 0:requestedScale = 0,水印不展示
sizeValue = 100:完整水印源文件最长边 = 底图最长边的 150%
完整水印与有效内容的请求渲染尺寸:
requestedSourceW = sourceW * requestedScale
requestedSourceH = sourceH * requestedScale
requestedContentW = contentW * requestedScale
requestedContentH = contentH * requestedScale
大小基准使用完整源文件而不是有效内容包围盒,因此 PNG 透明留白包含在 150% 文件尺寸内。有效内容仍用于位置、锚点和覆盖指标。
因此,不同分辨率不会导致水印忽大忽小。示例图是 1080P、目标原图是 4K 或 8K 时,水印像素尺寸会按底图长边同比例增大。
5. 位置参数与不可变规则
用户正在编辑的参考底图上,位置值表示有效内容中心:
normalizedX = (positionX + 100) / 200
normalizedY = (positionY + 100) / 200
referenceCenterX = normalizedX * referenceW
referenceCenterY = normalizedY * referenceH
参数含义:
positionX = -100 / 0 / 100:有效内容中心在参考底图的左边界 / 水平中心 / 右边界
positionY = -100 / 0 / 100:有效内容中心在参考底图的上边界 / 垂直中心 / 下边界
当配置应用到其他底图时,每个坐标轴独立判断:中轴锚点继续使用用户位置值映射后的归一化中心,边缘锚点使用短边归一化边距。边缘锚点换算可以使目标图上的实际中心位置变化,但这是内部渲染结果,绝对不得回写到 positionX、positionY 或滑杆。
必须遵守单向数据流:
用户参数 -> 保存的锚点配置 -> 目标图渲染几何
禁止以下反向回写:
目标图渲染几何 -X-> positionX / positionY
目标图渲染几何 -X-> 锚点偏移
目标图覆盖率 -X-> 水印大小或位置
这条规则用于避免 1:1 -> 9:16 -> 16:9 -> 3:4 -> 4:3 -> 2:3 -> 3:2 -> 1:1 往返切换后产生累积偏移。
6. 内部九宫格锚点
锚点只用于跨底图换算,不改变用户数值、滑杆范围和当前编辑画面。
anchorX、anchorY ∈ {0, 0.5, 1}
| 锚点 | anchorX | anchorY | 水印参考点 |
|---|---|---|---|
| 左上 | 0 | 0 | 有效内容左上角 |
| 上中 | 0.5 | 0 | 有效内容上边中点 |
| 右上 | 1 | 0 | 有效内容右上角 |
| 左中 | 0 | 0.5 | 有效内容左边中点 |
| 中心 | 0.5 | 0.5 | 有效内容中心 |
| 右中 | 1 | 0.5 | 有效内容右边中点 |
| 左下 | 0 | 1 | 有效内容左下角 |
| 下中 | 0.5 | 1 | 有效内容下边中点 |
| 右下 | 1 | 1 | 有效内容右下角 |
在参考底图上,对九个候选锚点计算归一化距离:
watermarkPointX = contentLeft + anchorX * renderContentW
watermarkPointY = contentTop + anchorY * renderContentH
canvasPointX = anchorX * W
canvasPointY = anchorY * H
distance = hypot(
watermarkPointX - canvasPointX,
watermarkPointY - canvasPointY
) / min(W, H)
选择距离最小的候选锚点。锚点变化时只更新内部保存值,不得立即重新摆放当前水印。
7. 统一水印的跨图位置换算
参考底图上保存锚点相对画布对应锚点的短边归一化偏移:
offsetXRatio = (watermarkPointX - canvasPointX) / min(referenceW, referenceH)
offsetYRatio = (watermarkPointY - canvasPointY) / min(referenceW, referenceH)
应用到目标底图:
targetR = min(targetW, targetH)
if anchorX == 0.5:
targetPointX = (positionX + 100) / 200 * targetW
else:
targetPointX = anchorX * targetW + offsetXRatio * targetR
if anchorY == 0.5:
targetPointY = (positionY + 100) / 200 * targetH
else:
targetPointY = anchorY * targetH + offsetYRatio * targetR
targetContentLeft = targetPointX - anchorX * targetContentW
targetContentTop = targetPointY - anchorY * targetContentH
该按轴处理规则的语义如下:
- 居中或接近居中的水印保持水平、垂直百分比,切换横竖图时不会产生中轴偏移。
- 靠左、靠右、靠上、靠下的坐标轴保持短边归一化边距。
- 例如“右中”锚点:水平轴保持右边距,垂直轴保持
positionY映射后的归一化中心。
例如右上锚点:
rightMargin = -offsetXRatio * min(W, H)
topMargin = offsetYRatio * min(W, H)
因此同一个右上小水印在横图、竖图中都能保持接近的上边距和右边距。
8. 参考配置的生成
只有用户真正编辑水印时,才以当前底图作为参考底图生成新的派生配置:
用户修改 sizeValue
用户修改 positionX / positionY
用户拖动水印并结束拖动
用户更换水印素材
生成顺序必须固定:
- 校验水印素材、底图和用户参数。
- 按第 4 节计算完整水印文件的请求缩放值。
- 按第 5 节将
positionX / positionY映射为有效内容中心。 - 由有效内容中心反推完整水印文件左上角。
- 对九个候选锚点计算距离并选择内部锚点。
- 按参考底图短边保存锚点偏移比例。
- 保存用户原始值、素材元数据、锚点配置和版本号。
若 sizeValue = 0,预览不展示水印,但仍按位置值推断零尺寸有效内容中心最接近的锚点,以便用户再次增大尺寸时保持位置语义。
覆盖率可按以下公式计算并用于调试、验收和监控,但不得保存为布局模式,不得改变缩放值:
overlapLeft = max(contentLeft, 0)
overlapTop = max(contentTop, 0)
overlapRight = min(contentRight, W)
overlapBottom = min(contentBottom, H)
overlapW = max(0, overlapRight - overlapLeft)
overlapH = max(0, overlapBottom - overlapTop)
visibleCoverage = overlapW * overlapH / (W * H)
9. 目标图统一换算
每张目标图都使用同一条换算链路,不区分角标水印和大面积水印:
- 先按 EXIF 方向归一化目标底图,再取得
targetW / targetH。 - 按目标底图长边和
sizeValue重新计算完整水印文件scale。 - 按第 7 节逐轴计算目标锚点位置。
- 使用目标锚点和有效内容渲染尺寸计算有效内容左上角。
- 根据有效内容包围盒在完整源文件中的偏移,反推完整源文件左上角。
- 完整源文件等比渲染,允许部分或全部超出画布。
- 应用素材自身 Alpha 和全局
opacity,从无水印母版开始合成。 - 最终结果裁剪到目标底图
targetW x targetH。
完整源文件位置公式:
targetContentLeft = targetPointX - anchorX * targetContentW
targetContentTop = targetPointY - anchorY * targetContentH
targetSourceLeft = targetContentLeft - contentX * scale
targetSourceTop = targetContentTop - contentY * scale
禁止在目标图阶段执行以下行为:
根据覆盖率追加放大
自动居中、吸附或移回画布
拉伸水印以匹配目标长宽比
把目标图上的像素结果回写为全局参数
10. 参考配置更新时机
只有以下操作触发参考配置重建:
用户修改 sizeValue
用户修改 positionX / positionY
用户拖动水印并结束拖动
用户更换水印素材
执行上述操作的当前底图成为新的参考底图。
以下操作不得触发参考配置重建:
只切换底图比例
只切换任意分辨率(包含 1K / 4K / 8K 及更高分辨率)
页面缩放或窗口尺寸变化
输出合成任务处理不同尺寸图片
11. 透明区域与裁剪
大小按完整水印源文件计算;位置、锚点和覆盖指标按有效内容计算;最终始终渲染完整水印源文件:
计算有效内容尺寸和锚点
-> 反推完整源文件位置
-> 渲染完整水印源文件
-> 应用不透明度
-> 与底图合成
-> 裁剪到 W * H
完整水印源文件和 PNG 透明区域允许超出底图,不得因为超出而自动缩小。
12. 保存原则
- 保存用户原始参数、水印素材元数据和生成后的派生
profile。 profile只包含参考底图比例、内部锚点、短边归一化偏移和算法语义标识,不包含铺满模式或覆盖目标。- 禁止保存示例图上的水印像素宽高或像素坐标作为目标图输入。
- 完整建议数据结构见第 14 节。
13. 系统职责与唯一真值源
13.1 几何引擎
研发应将本文档中的大小、位置、锚点和跨图适配实现为无 UI 依赖的纯函数几何引擎。覆盖率是派生观测值,不参与布局。预览端与导出端不得各自发明一套算法。
如果客户端和后端使用不同语言,必须:
- 共享同一份参数定义和公式规范。
- 共享同一组黄金测试向量(输入 JSON + 预期输出 JSON)。
- 保存
algorithmVersion,不允许无版本更换算法。
13.2 分层职责
| 模块 | 必须负责 | 不得负责 |
|---|---|---|
| 水印上传服务 | 格式校验、EXIF 方向归一化、解码安全校验、提取有效内容包围盒 | 根据某一张底图写死水印尺寸 |
| 客户端预览 | 收集用户值、生成参考配置、展示目标图派生几何 | 切换底图时回写用户值 |
| 配置服务 | 保存用户参数、派生配置、版本号和水印素材引用 | 保存示例图像素坐标作为跨图依据 |
| 合成服务 | 从无水印母版开始合成,按配置版本生成输出 | 在旧的带水印图上继续叠加 |
| 发布任务系统 | 幂等、去重、版本校验、原子替换输出 | 让旧任务结果覆盖新配置结果 |
14. 数据模型与接口契约
14.1 建议持久化结构
{
"schemaVersion": 2,
"algorithmVersion": "watermark-geometry-v2",
"configVersion": 42,
"watermarkAsset": {
"assetId": "wm_123",
"sourceHash": "sha256:...",
"format": "png",
"sourceW": 1536,
"sourceH": 1024,
"contentX": 82,
"contentY": 82,
"contentW": 1372,
"contentH": 860,
"alphaThreshold": 0.05,
"orientationNormalized": true
},
"userSettings": {
"enabled": true,
"sizeValue": 100,
"positionX": 80,
"positionY": -78,
"opacity": 62
},
"profile": {
"referenceAspectRatio": 1.5,
"anchorX": 1,
"anchorY": 0,
"offsetXRatio": -0.1,
"offsetYRatio": 0.11,
"sizeBasis": "source-long-over-canvas-long",
"positionSchema": "signed-center-v1",
"placementStrategy": "semantic-anchor-v2"
},
"migration": {
"sourceSchemaVersion": 1,
"state": "completed",
"migrationRevision": 1
}
}
14.2 字段权威性
userSettings是用户可见、可编辑的唯一权威值。profile是用户在参考底图上确认配置时生成的派生值,目标图换算不得改写它。watermarkAsset.content*与sourceHash必须绑定。水印文件变更后必须重新提取包围盒,不得复用旧元数据。configVersion在每次用户确认保存后单调递增。- 禁止保存某张目标图上的
renderW / renderH / renderX / renderY作为全局配置。 sizeBasis、positionSchema和placementStrategy必须显式保存,避免新旧算法使用相同数值但解释不同。migration只记录数据升级状态,不属于用户设置,不得单独触发水印重算任务。
14.3 历史数据兼容与迁移
14.3.1 新旧参数语义
当前线上历史配置使用:
sizeValue:1~100
positionX:0~100
positionY:0~100
opacity:0~100
新版本使用:
sizeValue:0~100
positionX:-100~100
positionY:-100~100
opacity:0~100
在已确认的前提下,线上旧版和新版的大小步长语义一致:每增加 1,完整水印文件最长边增加底图最长边的 1.5%。因此大小和不透明度不需要重新缩放,只扩展大小的下边界;位置需要进行线性坐标转换。
| 字段 | 历史范围 | 新范围 | 迁移规则 |
|---|---|---|---|
enabled |
布尔值 | 布尔值 | 原值保留 |
sizeValue |
1~100 | 0~100 | 原值保留,旧值 1 仍为 1,不得转成 0 |
positionX |
0~100 | -100~100 | newX = oldX * 2 - 100 |
positionY |
0~100 | -100~100 | newY = oldY * 2 - 100 |
opacity |
0~100 | 0~100 | 原值保留 |
位置映射示例:
| 历史值 | 新值 | 位置语义 |
|---|---|---|
| 0 | -100 | 左边界 / 上边界 |
| 10 | -80 | 靠左 / 靠上 |
| 25 | -50 | 画布前四分之一位置 |
| 50 | 0 | 画布中心 |
| 75 | 50 | 画布后四分之一位置 |
| 90 | 80 | 靠右 / 靠下 |
| 100 | 100 | 右边界 / 下边界 |
14.3.2 位置迁移的等价性
历史位置中心公式:
oldCenterX = oldPositionX / 100 * W
新位置中心公式:
newPositionX = oldPositionX * 2 - 100
newCenterX = (newPositionX + 100) / 200 * W
代入后:
newCenterX
= (oldPositionX * 2 - 100 + 100) / 200 * W
= oldPositionX / 100 * W
= oldCenterX
垂直轴同理。因此线性转换本身不会改变水印有效内容中心,转换过程不得取整;历史值包含小数时使用 64 位浮点数保存结果。
14.3.3 迁移总体流程
flowchart TD
A["读取项目水印配置"] --> B{"schemaVersion / positionSchema 是否已是新版"}
B -->|"是"| B1["直接使用;禁止再次转换"]
B -->|"否"| C["校验旧字段是否完整且位于历史合法范围"]
C --> D{"旧数据是否合法"}
D -->|"否"| D1["保留旧算法读取;记录异常并要求人工或用户确认"]
D -->|"是"| E["size、opacity、enabled 原值保留"]
E --> F["positionX/Y 执行 old * 2 - 100"]
F --> G["写入 sizeBasis 和 positionSchema"]
G --> H{"是否存在可靠的参考画布和素材包围盒"}
H -->|"是"| I["按参考画布生成 semantic-anchor-v2 profile"]
H -->|"否"| J["生成 legacy-percent-v1 兼容 profile"]
I --> K["在黄金画布上执行迁移前后几何等价校验"]
J --> K
K --> L{"误差是否满足阈值"}
L -->|"否"| L1["终止回写;继续使用旧配置并告警"]
L -->|"是"| M["事务写入 schemaVersion 2 和 migrationRevision"]
M --> N["保持 configVersion 不变;不触发重新加水印"]
N --> O["用户下次明确确认设置时切换为 semantic-anchor-v2"]
14.3.4 新锚点配置的兼容策略
历史配置只有百分比位置时,不能使用“迁移过程中碰到的第一张项目图片”临时推断锚点,否则同一份历史数据可能因为任务顺序不同得到不同结果。
按以下优先级处理:
- 存在可靠参考画布:若历史配置保存了用户设置时的参考底图宽高、比例或等价信息,使用该参考画布和水印素材包围盒生成
semantic-anchor-v2。 - 缺少参考画布:生成
placementStrategy = legacy-percent-v1。目标图的两个轴均继续按迁移后的中心位置值计算,不使用边缘锚点偏移,以保证历史效果不被猜测性改变。 - 用户明确确认设置:用户在新版设置页修改参数、拖动水印或点击确认后,以当前预览底图生成
semantic-anchor-v2,configVersion + 1,再按正常规则更新已发布图片。 - 用户只打开设置页、切换预览底图或取消设置时,不得把兼容配置升级成语义锚点配置。
兼容解析公式:
if profile.placementStrategy == "legacy-percent-v1":
targetCenterX = (positionX + 100) / 200 * targetW
targetCenterY = (positionY + 100) / 200 * targetH
else:
targetAnchorPoint = resolveSemanticAnchor(...)
14.3.5 迁移时机、版本和任务处理
推荐采用“读取兼容 + 后台幂等回填”,而不是发布版本时一次性阻塞迁移全部项目:
- 配置服务读取到
schemaVersion = 1时,先在内存中转换为 v2 结构,确保新版客户端可以正常展示。 - 转换通过等价校验后,异步回填 v2 数据;批量后台任务可补齐长期未访问项目。
- 数据结构迁移不得增加用户
configVersion,可单独增加migrationRevision。 - 仅发生 schema 迁移时,不重新处理已发布图片,不刷新 H5,不改变图片发布时间和发布状态。
- 用户迁移后第一次明确保存设置时,才增加
configVersion并执行正常的水印更新任务。 - 回填使用 compare-and-swap 或数据库事务校验记录版本;若迁移期间用户已修改配置,放弃旧迁移结果并基于最新记录重试。
迁移必须幂等:
if schemaVersion >= 2
&& positionSchema == "signed-center-v1":
return config // 不得再次执行 old * 2 - 100
14.3.6 异常数据处理
- 不得使用
value || defaultValue读取历史数值。position = 0和opacity = 0都是合法值,不能被当作空值。 - 历史
sizeValue合法范围是1~100。若历史记录出现 0,不得直接解释为新版“不展示”,应视为异常数据并继续使用旧版兼容读取结果。 - 历史位置或透明度越界时,不得直接钳制后静默覆盖数据库;应记录原值、项目 ID 和错误原因,进入异常迁移队列。
- 缺少水印素材、素材哈希不一致或无法解码时,不生成空白新配置,保留旧配置与旧发布图。
- 迁移写入失败不得影响项目继续使用旧算法读取;重试使用固定迁移幂等键。
migrationKey = projectId + sourceSchemaVersion + migrationRevision
14.3.7 历史数据迁移验收
oldPosition = 0 / 50 / 100迁移后必须分别得到-100 / 0 / 100。- 遍历旧位置整数
0~100,迁移前后的有效内容中心像素误差小于0.01 px。 - 遍历旧大小整数
1~100,迁移后sizeValue与旧值完全相同。 - 遍历旧透明度整数
0~100,迁移后值完全相同。 - 同一条 v1 配置连续执行迁移 3 次,只允许第一次改变数据,后两次结果必须逐字段一致。
- 仅完成 schema 迁移时,项目中已发布图片数量、文件哈希、发布状态和 H5 展示不得变化。
- 缺少参考画布的项目迁移后必须使用
legacy-percent-v1,不得根据任意项目图片推断锚点。 - 用户在兼容配置上明确确认后,必须生成
semantic-anchor-v2和新的configVersion。 - 迁移任务与用户保存并发时,最终只能保留用户最新配置,不得被旧迁移结果覆盖。
若某个更早的 algorithmVersion 使用了不同的大小基准,不得套用“大小原值保留”规则。必须按该版本的历史公式恢复实际渲染长边,再换算为 v2 sizeValue;无法可靠恢复时继续保留旧算法读取,等待用户重新确认。
14.4 几何引擎接口
buildWatermarkProfile(
userSettings,
watermarkAssetMeta,
referenceCanvas
) -> profile
resolveWatermarkGeometry(
userSettings,
watermarkAssetMeta,
profile,
targetCanvas
) -> {
sourceLeft,
sourceTop,
sourceRenderW,
sourceRenderH,
contentLeft,
contentTop,
contentRenderW,
contentRenderH,
scale,
visibleCoverage,
potentialCoverage
}
两个接口必须是纯函数:相同输入永远返回相同输出,不读取 UI 状态、窗口尺寸、设备像素比或任务队列状态。
15. 核心算法伪代码
15.1 生成参考配置
function buildWatermarkProfile(settings, wm, reference):
validateInputs(settings, wm, reference)
canvasLong = max(reference.W, reference.H)
sourceLong = max(wm.sourceW, wm.sourceH)
sizeRatio = settings.sizeValue * 0.015
scale = canvasLong * sizeRatio / sourceLong
sizing = getRenderSizing(wm, scale)
center = (
(settings.positionX + 100) / 200 * reference.W,
(settings.positionY + 100) / 200 * reference.H
)
geometry = geometryAtCenter(wm, sizing, center)
anchor = inferNearestNineGridAnchor(reference, geometry)
offsets = normalizeAnchorOffsetByShortSide(reference, geometry, anchor)
return {
referenceAspectRatio: reference.W / reference.H,
anchorX: anchor.x,
anchorY: anchor.y,
offsetXRatio: offsets.x,
offsetYRatio: offsets.y,
sizeBasis: "source-long-over-canvas-long",
positionSchema: "signed-center-v1",
placementStrategy: "semantic-anchor-v2"
}
15.2 应用到目标图
function resolveWatermarkGeometry(settings, wm, profile, target):
validateInputs(settings, wm, target)
if !settings.enabled || settings.sizeValue == 0 || settings.opacity == 0:
return invisibleGeometry()
canvasLong = max(target.W, target.H)
sourceLong = max(wm.sourceW, wm.sourceH)
sizeRatio = settings.sizeValue * 0.015
scale = canvasLong * sizeRatio / sourceLong
sourceRenderW = wm.sourceW * scale
sourceRenderH = wm.sourceH * scale
contentRenderW = wm.contentW * scale
contentRenderH = wm.contentH * scale
if profile.placementStrategy == "legacy-percent-v1":
anchorPoint = {
x: (settings.positionX + 100) / 200 * target.W,
y: (settings.positionY + 100) / 200 * target.H
}
activeAnchorX = 0.5
activeAnchorY = 0.5
else:
anchorPoint = resolveTargetAnchorPoint(
settings.positionX,
settings.positionY,
profile.anchorX,
profile.anchorY,
profile.offsetXRatio,
profile.offsetYRatio,
target
)
activeAnchorX = profile.anchorX
activeAnchorY = profile.anchorY
contentLeft = anchorPoint.x - activeAnchorX * contentRenderW
contentTop = anchorPoint.y - activeAnchorY * contentRenderH
sourceLeft = contentLeft - wm.contentX * scale
sourceTop = contentTop - wm.contentY * scale
return {
sourceLeft,
sourceTop,
sourceRenderW,
sourceRenderH,
contentLeft,
contentTop,
contentRenderW,
contentRenderH,
scale,
opacity: settings.opacity / 100
}
15.3 数值规则
- 中间几何计算统一使用 64 位浮点数。
- 中间步骤不得四舍五入。只在最终光栅化时转换像素边界。
- 预览与导出必须统一最终取整策略,建议使用半像素中心模型和
round-half-away-from-zero。 sizeValue / opacity在 UI 输入完成时取整数;位置值允许保留两位小数,几何引擎不得提前截断。
16. 保存、合成与异步任务一致性
16.1 保存时机
- 用户拖动、调整滑杆时,客户端可实时重算预览。
- 只有用户点击“确认”后,才生成新
configVersion并启动项目图片处理。 - 用户只切换 Demo/预览底图时,不生成新配置,不触发任务。
16.2 母版与输出
每次生成水印图都必须从“无水印的已修图母版”开始:
无水印已修图母版
-> 按 configVersion 计算水印
-> 生成新的带水印发布图
-> 成功后原子替换旧发布图
禁止:
旧带水印图 -> 再叠加新水印
原图根目录文件和无水印已修图母版不得被改写。
16.3 任务版本与幂等
idempotencyKey =
projectId + imageId + configVersion + algorithmVersion
- 任务开始前读取目标
configVersion。 - 合成完成、替换输出前再校验一次当前项目
configVersion。 - 若任务版本已过期,丢弃输出,不刷新发布图,新版本任务继续处理。
- 相同幂等键重试不得生成多份有效输出或多次刷新。
- 单图失败不得影响其他图片,并应支持单图重试。
17. 边界场景与异常处理
| 场景 | 预期处理 | 禁止行为 |
|---|---|---|
PNG 全透明,不存在 Alpha >= 5% 像素 |
判定无有效内容,拒绝使用并保留旧配置 | 使用 1 x 1 伪包围盒继续合成 |
| PNG 只有少量半透明像素 | 严格使用 5% Alpha 阈值;预览和导出使用同一包围盒 | 某一端自行做连通域清理 |
| JPG/JPEG 无 Alpha | 整张图为有效内容 | 尝试用白色/黑色背景猜测透明区 |
| EXIF 方向为 3/6/8 | 先完成方向归一化,再读取宽高、包围盒和锚点 | 使用未旋转像素尺寸参与布局 |
| 水印或底图宽高为 0、NaN、Infinity | 参数校验失败,不创建任务 | 进入除零或带病合成 |
| 文件小但解码后像素超大 | 同时校验文件大小、宽高和解码像素总量 | 仅依赖 10MB 文件大小限制 |
| 1K 水印应用到 8K/超高清底图 | 保持几何比例;清晰度不足时可警告,但不改大小 | 因素材分辨率低而自动缩小水印 |
| 8K 水印应用到 1K 底图 | 按同一几何比例缩小,使用高质量降采样 | 使用水印原像素尺寸直接合成 |
| 水印长宽比极端,如 20:1 或 1:20 | 仍按完整文件长边 / 底图长边缩放,允许大量溢出后裁剪 | 拉伸或强制改变水印长宽比 |
| 底图比例极端,如 1:10 或 10:1 | 按相同算法计算;超过图像引擎上限时明确失败或分块合成 | 临时改用另一套尺寸基准 |
| 水印完全移出画布 | 允许 visibleCoverage = 0,最终输出无可见水印 |
强制吸附回画布 |
sizeValue = 0 |
scale = 0,预览和输出均不展示水印,但配置值有效 |
自动改成 1 或清空其他设置 |
sizeValue = 100 |
完整水印文件最长边等于底图最长边的 150%,允许出画 | 按有效内容框或底图短边解释 150% |
opacity = 0 |
输出无可见水印,其他几何参数保持不变 | 删除水印素材或重置位置 |
positionX = -100 / 0 / 100 |
有效内容中心位于左边界 / 水平中心 / 右边界 | 继续按旧的 0 / 50 / 100 解释 |
positionY = -100 / 0 / 100 |
有效内容中心位于上边界 / 垂直中心 / 下边界 | 继续按旧的 0 / 50 / 100 解释 |
| 用户输入越界值 | UI 取最近边界;服务端拒绝未规范化的非法持久化请求 | 客户端与服务端各自使用不同越界值 |
| 九宫格候选锚点距离相同 | 优先保留上一次锚点;无历史值时选中心 | 随机选择导致跨端不一致 |
| 更换水印素材 | 重新提取包围盒,并在当前参考底图上重新生成锚点配置 | 保留旧水印素材的包围盒和派生位置 |
| 用户快速连续确认多次配置 | 每次生成新版本,最终只允许最新版本替换发布图 | 按任务完成时间直接覆盖 |
| 单图合成中途失败 | 保留旧发布图,记录错误并支持重试 | 先删除旧图再开始生成 |
| 水印素材在任务中被删除 | 任务按 assetId + sourceHash 校验后失败,不产生空白或错误水印图 |
用同名的其他文件替代 |
旧配置使用位置 0~100 |
按第 14.3 节线性迁移;缺少参考画布时使用 legacy-percent-v1 |
不迁移数值而直接按新语义解释,或用任意项目图片猜锚点 |
旧配置缺少 profile 字段 |
按 schemaVersion 走明确迁移;无法可靠重建时保留旧效果并要求重新确认 |
以 0 填充导致水印跳动 |
17.1 上传错误文案
既有格式和文件大小规则不变:JPG、JPEG、PNG,文件大小不超过 10MB。
| 场景 | 用户提示 |
|---|---|
| 格式、文件大小、尺寸或解码安全校验失败 | 仅支持上传 10MB 以内的 JPG、PNG 图片。 |
| PNG 无有效内容 | 该图片没有可见内容,请更换水印图片。 |
| 上传或解析服务异常 | 水印上传失败,请重试。 |
上传失败后必须保留原水印和当前设置,不得清空项目已生效配置。
18. 性能、安全、质量与可观测性
18.1 性能要求
- 水印有效内容包围盒在素材上传后提取一次并缓存,不得为每张底图重复扫描 Alpha。
- 编辑器使用缩略图预览,但几何必须以最终输出宽高归一化计算。
- 4K/8K 合成不得依赖前端 Canvas 生成最终文件,应由后台图像引擎处理。
- 对超大图像使用流式或分块合成,避免同时持有多份完整 RGBA 缓冲区。
- 同一目标图的几何只计算一次;不得因覆盖率或画布相交状态重复执行图像缩放。
18.2 图像质量要求
- 水印只能等比缩放。
- 缩小使用高质量降采样滤镜;放大时不做人工锐化。
- PNG 合成使用预乘 Alpha 或图像引擎的等价正确实现,避免边缘黑边/白边。
- 应用
opacity时,将素材 Alpha 与全局不透明度相乘,不覆盖素材自身 Alpha。 - 保留底图 ICC 色彩配置文件或按产品统一色彩空间转换;预览与导出策略必须一致。
18.3 安全要求
- 不信任扩展名和 MIME,使用文件头魔数二次校验。
- 限制解码像素总量、单边尺寸、帧数和解码时间,防止图像解压缩炸弹。
- 禁止将 SVG、HTML 或其他可执行内容作为图片水印解析。
- 水印访问 URL 必须是内部受控对象存储地址,合成服务不接受用户任意外部 URL,防止 SSRF。
18.4 日志与监控
每次合成建议记录:
projectId / imageId
configVersion / algorithmVersion
targetW / targetH
watermarkAssetId / sourceHash
placementStrategy / positionSchema / sizeBasis
anchorX / anchorY / offsetXRatio / offsetYRatio
sizeValue / positionX / positionY / opacity
sourceLongRatio / scale
potentialCoverage / visibleCoverage
durationMs / peakMemoryBucket
result / errorCode
建议监控指标:单图合成成功率、P50/P95/P99 耗时、8K 图片失败率、过期任务丢弃数、单项目重试次数、预览/导出几何不一致告警。
19. 验收要求
- 四项用户参数的合法范围必须分别为:大小
0~100、水平位置-100~100、垂直位置-100~100、不透明度0~100。 - 设置一组参数后,任意切换底图,四个用户值必须完全不变。
sizeValue = 0时,预览和最终输出均无可见水印;再次增大尺寸后,原位置值保持不变。opacity = 0时,预览和最终输出均无可见水印,大小和位置值不得被重置。sizeValue = 100时,完整水印文件最长边 / 目标底图最长边必须为150%,误差小于0.01%。- Demo 的
3:2水印应用到3:2底图,大小100、位置0 / 0时,完整文件长边比例为150%,可见覆盖率为100%。 positionX = -100 / 0 / 100必须分别映射为有效内容中心位于左边界 / 水平中心 / 右边界;垂直轴同理。- 执行
1:1 -> 9:16 -> 16:9 -> 3:4 -> 4:3 -> 2:3 -> 3:2 -> 4K 2:3 -> 1:1后,返回时水印归一化尺寸和位置误差小于0.01%。 - 同一比例的 1K、4K、8K 底图上,完整水印文件最长边 / 底图最长边的比例误差小于
0.01%。 - 水印源素材为同构 1K 和 8K 图片时,应用到同一底图后的归一化几何一致,差异只允许来自重采样清晰度。
- 右上水印切换所有支持比例时,上边距与右边距占各自底图短边的比例误差小于
1%。 - 中轴锚点水印切换所有支持比例时,按
position映射后的归一化中心误差小于0.01%。 - 任意大小和位置组合均不得触发覆盖率驱动的追加缩放、自动居中或模式切换。
- 水印部分或全部超出画布时不得自动缩小或吸附;完全出画允许
visibleCoverage = 0。 - PNG 透明留白完整保留;透明留白参与完整文件尺寸,不能影响有效内容中心和锚点测量。
- 全透明 PNG、宽高非法图片、解码超限图片均不得进入合成队列。
- 预览与最终导出必须使用同一套几何计算函数和相同取整规则。
- 本期 UI、持久化模型和几何引擎不得出现旋转、铺满模式、覆盖目标或二分补足字段。
- 对同一组黄金测试向量,客户端与后端输出的归一化几何差异小于
0.01%。 - 对 EXIF 方向 1/3/6/8 的横竖图,归一化后的大小、锚点和边距结果符合显示方向。
- 同一张图连续修改水印 3 次,最终发布图只能包含第 3 个配置的一层水印。
- 旧版本任务晚于新版本完成时,旧任务输出不得替换新发布图;幂等重试 3 次只产生一份最终有效输出。
- 合成失败或任务超时时,旧发布图仍可访问;原图根目录文件和无水印母版文件哈希保持不变。
- 端到端回归至少覆盖:9 个锚点、7 种底图比例、6 种 Demo 水印比例、大小和位置边界值,以及 1K / 4K / 8K 分辨率。
- 历史配置迁移必须通过第 14.3.7 节全部用例,且 schema 迁移本身不得触发图片重新加水印。
20. 研发交付清单
- 无 UI 依赖的几何引擎及单元测试。
- 水印元数据提取与安全校验。
- 客户端预览接入,切换底图不回写参数。
- 后台合成接入,支持高分辨率和透明 Alpha。
schemaVersion / algorithmVersion / configVersion与存量配置迁移。- 幂等任务、过期版本防护和单图重试。
- 黄金测试向量、端到端回归和性能测试报告。
- 监控指标、错误码和问题排查日志。