水印研发规则返回 Demo

水印跨底图自适应研发需求文档

文档状态:待研发评审
适用范围:图片水印的预览、配置保存、后台合成与跨比例重算
配套 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 产品目标

本期优化的核心目标是:用户只设置一次水印样式,同一组参数应用到不同分辨率、不同横竖比例的底图时,水印的视觉大小、位置关系和整体构图意图应基本一致,不应因为底图比例或像素尺寸变化而出现不可预测的放大、缩小或位置漂移。

  1. 用户只设置一组水印参数,可应用到项目内所有图片。
  2. 相同长宽比的 1K、4K、8K 图片,水印的归一化大小和位置一致。
  3. 角标水印在横图、竖图和方图上保持对应的语义锚点,以及距离最近画面边缘的相对边距。
  4. 大面积水印仍使用同一套大小与位置规则,不自动进入另一种模式,不根据覆盖率二次放大。
  5. 水印允许超出画布,超出部分在最终合成时裁剪;不得为了留在画布内而自动缩小或吸附。
  6. 预览与最终导出使用同一套几何规则,结果可验证、可回归。
  7. 水印配置多次修改、异步重算和失败重试时,不得叠加旧水印或用旧任务覆盖新结果。

1.3 本期范围

用户只设置一组项目全局参数:

sizeValue:大小,0~100
positionX:水平位置,-100~100
positionY:垂直位置,-100~100
opacity:不透明度,0~100

同一组参数应用到 9:1616:92:33:23:44:31:1 及不同分辨率底图时,必须满足:

  1. 切换底图不得改写 sizeValuepositionXpositionYopacity
  2. 水印完整源文件的最长边,始终按目标底图最长边的固定比例换算。
  3. 边缘水印保持短边归一化语义边距,中轴水印保持用户位置值对应的归一化中心。
  4. sizeValue = 0opacity = 0 时,水印不可见。
  5. 从任意底图切回原底图时,水印的尺寸和位置必须精确恢复,不得累积偏移。

本期只有一套统一水印换算规则,不进行铺满判断,不支持旋转。

本逻辑不改动既有产品默认值:

参数 范围 产品默认值
大小 sizeValue 0~100,整数 5
水平位置 positionX -100~100 10
垂直位置 positionY -100~100 7
不透明度 opacity 0~100 100

Demo 中的“重置”是用于验证右上角边距的测试预设,不代表产品默认值。

1.4 非本期范围

  1. 文字水印、平铺重复水印和多水印图层。
  2. 水印旋转、透视变换、非等比拉伸。
  3. 自动铺满、手动选择“适应 / 填满”模式、多比例水印素材自动匹配。
  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:有效内容中心在参考底图的上边界 / 垂直中心 / 下边界

当配置应用到其他底图时,每个坐标轴独立判断:中轴锚点继续使用用户位置值映射后的归一化中心,边缘锚点使用短边归一化边距。边缘锚点换算可以使目标图上的实际中心位置变化,但这是内部渲染结果,绝对不得回写到 positionXpositionY 或滑杆。

必须遵守单向数据流:

用户参数 -> 保存的锚点配置 -> 目标图渲染几何

禁止以下反向回写:

目标图渲染几何 -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

该按轴处理规则的语义如下:

  1. 居中或接近居中的水印保持水平、垂直百分比,切换横竖图时不会产生中轴偏移。
  2. 靠左、靠右、靠上、靠下的坐标轴保持短边归一化边距。
  3. 例如“右中”锚点:水平轴保持右边距,垂直轴保持 positionY 映射后的归一化中心。

例如右上锚点:

rightMargin = -offsetXRatio * min(W, H)
topMargin = offsetYRatio * min(W, H)

因此同一个右上小水印在横图、竖图中都能保持接近的上边距和右边距。

8. 参考配置的生成

只有用户真正编辑水印时,才以当前底图作为参考底图生成新的派生配置:

用户修改 sizeValue
用户修改 positionX / positionY
用户拖动水印并结束拖动
用户更换水印素材

生成顺序必须固定:

  1. 校验水印素材、底图和用户参数。
  2. 按第 4 节计算完整水印文件的请求缩放值。
  3. 按第 5 节将 positionX / positionY 映射为有效内容中心。
  4. 由有效内容中心反推完整水印文件左上角。
  5. 对九个候选锚点计算距离并选择内部锚点。
  6. 按参考底图短边保存锚点偏移比例。
  7. 保存用户原始值、素材元数据、锚点配置和版本号。

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. 目标图统一换算

每张目标图都使用同一条换算链路,不区分角标水印和大面积水印:

  1. 先按 EXIF 方向归一化目标底图,再取得 targetW / targetH
  2. 按目标底图长边和 sizeValue 重新计算完整水印文件 scale
  3. 按第 7 节逐轴计算目标锚点位置。
  4. 使用目标锚点和有效内容渲染尺寸计算有效内容左上角。
  5. 根据有效内容包围盒在完整源文件中的偏移,反推完整源文件左上角。
  6. 完整源文件等比渲染,允许部分或全部超出画布。
  7. 应用素材自身 Alpha 和全局 opacity,从无水印母版开始合成。
  8. 最终结果裁剪到目标底图 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. 保存原则

  1. 保存用户原始参数、水印素材元数据和生成后的派生 profile
  2. profile 只包含参考底图比例、内部锚点、短边归一化偏移和算法语义标识,不包含铺满模式或覆盖目标。
  3. 禁止保存示例图上的水印像素宽高或像素坐标作为目标图输入。
  4. 完整建议数据结构见第 14 节。

13. 系统职责与唯一真值源

13.1 几何引擎

研发应将本文档中的大小、位置、锚点和跨图适配实现为无 UI 依赖的纯函数几何引擎。覆盖率是派生观测值,不参与布局。预览端与导出端不得各自发明一套算法。

如果客户端和后端使用不同语言,必须:

  1. 共享同一份参数定义和公式规范。
  2. 共享同一组黄金测试向量(输入 JSON + 预期输出 JSON)。
  3. 保存 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 字段权威性

  1. userSettings 是用户可见、可编辑的唯一权威值。
  2. profile 是用户在参考底图上确认配置时生成的派生值,目标图换算不得改写它。
  3. watermarkAsset.content*sourceHash 必须绑定。水印文件变更后必须重新提取包围盒,不得复用旧元数据。
  4. configVersion 在每次用户确认保存后单调递增。
  5. 禁止保存某张目标图上的 renderW / renderH / renderX / renderY 作为全局配置。
  6. sizeBasispositionSchemaplacementStrategy 必须显式保存,避免新旧算法使用相同数值但解释不同。
  7. 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 新锚点配置的兼容策略

历史配置只有百分比位置时,不能使用“迁移过程中碰到的第一张项目图片”临时推断锚点,否则同一份历史数据可能因为任务顺序不同得到不同结果。

按以下优先级处理:

  1. 存在可靠参考画布:若历史配置保存了用户设置时的参考底图宽高、比例或等价信息,使用该参考画布和水印素材包围盒生成 semantic-anchor-v2
  2. 缺少参考画布:生成 placementStrategy = legacy-percent-v1。目标图的两个轴均继续按迁移后的中心位置值计算,不使用边缘锚点偏移,以保证历史效果不被猜测性改变。
  3. 用户明确确认设置:用户在新版设置页修改参数、拖动水印或点击确认后,以当前预览底图生成 semantic-anchor-v2configVersion + 1,再按正常规则更新已发布图片。
  4. 用户只打开设置页、切换预览底图或取消设置时,不得把兼容配置升级成语义锚点配置。

兼容解析公式:

if profile.placementStrategy == "legacy-percent-v1":
    targetCenterX = (positionX + 100) / 200 * targetW
    targetCenterY = (positionY + 100) / 200 * targetH
else:
    targetAnchorPoint = resolveSemanticAnchor(...)

14.3.5 迁移时机、版本和任务处理

推荐采用“读取兼容 + 后台幂等回填”,而不是发布版本时一次性阻塞迁移全部项目:

  1. 配置服务读取到 schemaVersion = 1 时,先在内存中转换为 v2 结构,确保新版客户端可以正常展示。
  2. 转换通过等价校验后,异步回填 v2 数据;批量后台任务可补齐长期未访问项目。
  3. 数据结构迁移不得增加用户 configVersion,可单独增加 migrationRevision
  4. 仅发生 schema 迁移时,不重新处理已发布图片,不刷新 H5,不改变图片发布时间和发布状态。
  5. 用户迁移后第一次明确保存设置时,才增加 configVersion 并执行正常的水印更新任务。
  6. 回填使用 compare-and-swap 或数据库事务校验记录版本;若迁移期间用户已修改配置,放弃旧迁移结果并基于最新记录重试。

迁移必须幂等:

if schemaVersion >= 2
   && positionSchema == "signed-center-v1":
    return config  // 不得再次执行 old * 2 - 100

14.3.6 异常数据处理

  1. 不得使用 value || defaultValue 读取历史数值。position = 0opacity = 0 都是合法值,不能被当作空值。
  2. 历史 sizeValue 合法范围是 1~100。若历史记录出现 0,不得直接解释为新版“不展示”,应视为异常数据并继续使用旧版兼容读取结果。
  3. 历史位置或透明度越界时,不得直接钳制后静默覆盖数据库;应记录原值、项目 ID 和错误原因,进入异常迁移队列。
  4. 缺少水印素材、素材哈希不一致或无法解码时,不生成空白新配置,保留旧配置与旧发布图。
  5. 迁移写入失败不得影响项目继续使用旧算法读取;重试使用固定迁移幂等键。
migrationKey = projectId + sourceSchemaVersion + migrationRevision

14.3.7 历史数据迁移验收

  1. oldPosition = 0 / 50 / 100 迁移后必须分别得到 -100 / 0 / 100
  2. 遍历旧位置整数 0~100,迁移前后的有效内容中心像素误差小于 0.01 px
  3. 遍历旧大小整数 1~100,迁移后 sizeValue 与旧值完全相同。
  4. 遍历旧透明度整数 0~100,迁移后值完全相同。
  5. 同一条 v1 配置连续执行迁移 3 次,只允许第一次改变数据,后两次结果必须逐字段一致。
  6. 仅完成 schema 迁移时,项目中已发布图片数量、文件哈希、发布状态和 H5 展示不得变化。
  7. 缺少参考画布的项目迁移后必须使用 legacy-percent-v1,不得根据任意项目图片推断锚点。
  8. 用户在兼容配置上明确确认后,必须生成 semantic-anchor-v2 和新的 configVersion
  9. 迁移任务与用户保存并发时,最终只能保留用户最新配置,不得被旧迁移结果覆盖。

若某个更早的 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 数值规则

  1. 中间几何计算统一使用 64 位浮点数。
  2. 中间步骤不得四舍五入。只在最终光栅化时转换像素边界。
  3. 预览与导出必须统一最终取整策略,建议使用半像素中心模型和 round-half-away-from-zero
  4. sizeValue / opacity 在 UI 输入完成时取整数;位置值允许保留两位小数,几何引擎不得提前截断。

16. 保存、合成与异步任务一致性

16.1 保存时机

  1. 用户拖动、调整滑杆时,客户端可实时重算预览。
  2. 只有用户点击“确认”后,才生成新 configVersion 并启动项目图片处理。
  3. 用户只切换 Demo/预览底图时,不生成新配置,不触发任务。

16.2 母版与输出

每次生成水印图都必须从“无水印的已修图母版”开始:

无水印已修图母版
-> 按 configVersion 计算水印
-> 生成新的带水印发布图
-> 成功后原子替换旧发布图

禁止:

旧带水印图 -> 再叠加新水印

原图根目录文件和无水印已修图母版不得被改写。

16.3 任务版本与幂等

idempotencyKey =
projectId + imageId + configVersion + algorithmVersion
  1. 任务开始前读取目标 configVersion
  2. 合成完成、替换输出前再校验一次当前项目 configVersion
  3. 若任务版本已过期,丢弃输出,不刷新发布图,新版本任务继续处理。
  4. 相同幂等键重试不得生成多份有效输出或多次刷新。
  5. 单图失败不得影响其他图片,并应支持单图重试。

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 性能要求

  1. 水印有效内容包围盒在素材上传后提取一次并缓存,不得为每张底图重复扫描 Alpha。
  2. 编辑器使用缩略图预览,但几何必须以最终输出宽高归一化计算。
  3. 4K/8K 合成不得依赖前端 Canvas 生成最终文件,应由后台图像引擎处理。
  4. 对超大图像使用流式或分块合成,避免同时持有多份完整 RGBA 缓冲区。
  5. 同一目标图的几何只计算一次;不得因覆盖率或画布相交状态重复执行图像缩放。

18.2 图像质量要求

  1. 水印只能等比缩放。
  2. 缩小使用高质量降采样滤镜;放大时不做人工锐化。
  3. PNG 合成使用预乘 Alpha 或图像引擎的等价正确实现,避免边缘黑边/白边。
  4. 应用 opacity 时,将素材 Alpha 与全局不透明度相乘,不覆盖素材自身 Alpha。
  5. 保留底图 ICC 色彩配置文件或按产品统一色彩空间转换;预览与导出策略必须一致。

18.3 安全要求

  1. 不信任扩展名和 MIME,使用文件头魔数二次校验。
  2. 限制解码像素总量、单边尺寸、帧数和解码时间,防止图像解压缩炸弹。
  3. 禁止将 SVG、HTML 或其他可执行内容作为图片水印解析。
  4. 水印访问 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. 验收要求

  1. 四项用户参数的合法范围必须分别为:大小 0~100、水平位置 -100~100、垂直位置 -100~100、不透明度 0~100
  2. 设置一组参数后,任意切换底图,四个用户值必须完全不变。
  3. sizeValue = 0 时,预览和最终输出均无可见水印;再次增大尺寸后,原位置值保持不变。
  4. opacity = 0 时,预览和最终输出均无可见水印,大小和位置值不得被重置。
  5. sizeValue = 100 时,完整水印文件最长边 / 目标底图最长边必须为 150%,误差小于 0.01%
  6. Demo 的 3:2 水印应用到 3:2 底图,大小 100、位置 0 / 0 时,完整文件长边比例为 150%,可见覆盖率为 100%
  7. positionX = -100 / 0 / 100 必须分别映射为有效内容中心位于左边界 / 水平中心 / 右边界;垂直轴同理。
  8. 执行 1:1 -> 9:16 -> 16:9 -> 3:4 -> 4:3 -> 2:3 -> 3:2 -> 4K 2:3 -> 1:1 后,返回时水印归一化尺寸和位置误差小于 0.01%
  9. 同一比例的 1K、4K、8K 底图上,完整水印文件最长边 / 底图最长边的比例误差小于 0.01%
  10. 水印源素材为同构 1K 和 8K 图片时,应用到同一底图后的归一化几何一致,差异只允许来自重采样清晰度。
  11. 右上水印切换所有支持比例时,上边距与右边距占各自底图短边的比例误差小于 1%
  12. 中轴锚点水印切换所有支持比例时,按 position 映射后的归一化中心误差小于 0.01%
  13. 任意大小和位置组合均不得触发覆盖率驱动的追加缩放、自动居中或模式切换。
  14. 水印部分或全部超出画布时不得自动缩小或吸附;完全出画允许 visibleCoverage = 0
  15. PNG 透明留白完整保留;透明留白参与完整文件尺寸,不能影响有效内容中心和锚点测量。
  16. 全透明 PNG、宽高非法图片、解码超限图片均不得进入合成队列。
  17. 预览与最终导出必须使用同一套几何计算函数和相同取整规则。
  18. 本期 UI、持久化模型和几何引擎不得出现旋转、铺满模式、覆盖目标或二分补足字段。
  19. 对同一组黄金测试向量,客户端与后端输出的归一化几何差异小于 0.01%
  20. 对 EXIF 方向 1/3/6/8 的横竖图,归一化后的大小、锚点和边距结果符合显示方向。
  21. 同一张图连续修改水印 3 次,最终发布图只能包含第 3 个配置的一层水印。
  22. 旧版本任务晚于新版本完成时,旧任务输出不得替换新发布图;幂等重试 3 次只产生一份最终有效输出。
  23. 合成失败或任务超时时,旧发布图仍可访问;原图根目录文件和无水印母版文件哈希保持不变。
  24. 端到端回归至少覆盖:9 个锚点、7 种底图比例、6 种 Demo 水印比例、大小和位置边界值,以及 1K / 4K / 8K 分辨率。
  25. 历史配置迁移必须通过第 14.3.7 节全部用例,且 schema 迁移本身不得触发图片重新加水印。

20. 研发交付清单

  1. 无 UI 依赖的几何引擎及单元测试。
  2. 水印元数据提取与安全校验。
  3. 客户端预览接入,切换底图不回写参数。
  4. 后台合成接入,支持高分辨率和透明 Alpha。
  5. schemaVersion / algorithmVersion / configVersion 与存量配置迁移。
  6. 幂等任务、过期版本防护和单图重试。
  7. 黄金测试向量、端到端回归和性能测试报告。
  8. 监控指标、错误码和问题排查日志。