Files
photo-live/services/worker/src/watermark.ts
T
2026-09-20 17:53:44 +08:00

168 lines
7.0 KiB
TypeScript
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.
// services/worker/src/watermark.ts —— 水印合成流水线(P4a Task 3)。
// 实现方式登记(技术方案 7.2 / 计划执行注意):sharp 无原生文字渲染,文字水印
// 经 librsvg 渲染 SVG `<text>` 层(font-family="sans-serif" 走系统字体链;CJK
// 字形可用性取决于运行环境字体——e2e 以「输出非空且与无水印图不同」断言,
// 精确字形不可断言,环境缺 CJK 字体时渲染为替代框/空白,英文水印为兜底,
// G5 字体事项登记)。合成流程:
// ① 名义字号渲染文字 SVG → PNG → .trim() 得紧凑文字层(对字形宽度不做任何
// 假设——CJK 全宽 / Latin 变宽都由 trim 后实测尺寸消化);
// ② 文字层等比缩放到目标宽度 = 浏览图宽 × scalePercent%(scalePercent 语义
// 「水印尺寸相对浏览图宽度的百分比」);
// ③ 与浏览图基线(rotate + resize ≤1920,同 pipeline.ts 规格)composite,
// 位置按 config.position + 2% 边距,输出 JPEG q82(与 browse 变体同参,
// 便于字节级对比断言)。
// 不透明度经 SVG fill-opacity 承载(缩放不改变相对透明度)。
import sharp from 'sharp';
import type { StoredWatermarkConfig, WatermarkPosition } from '@photo/contracts';
import { BROWSE_MAX_WIDTH } from './pipeline';
/** 文字层与浏览图边缘的边距(浏览图宽的 2%,至少 4px;center 无边距)。 */
const MARGIN_RATIO = 0.02;
const MIN_MARGIN_PX = 4;
/** XML 属性/文本转义(用户可控的 content 进入 SVG 标记,防注入)。 */
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
/**
* 名义渲染:fontSize 即 SVG font-size(px @72dpi)。画布按「最宽可能」预估
* (每字符 2×fontSize + 两侧 padding)——后续 trim 收紧,宁可画大不裁字。
*/
function textSvg(config: StoredWatermarkConfig): string {
const fontSize = config.text.fontSize;
const content = escapeXml(config.text.content);
const canvasWidth = Math.ceil(content.length * fontSize * 2 + fontSize * 2);
const canvasHeight = Math.ceil(fontSize * 3);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}">` +
`<text x="${Math.ceil(fontSize / 2)}" y="${Math.ceil(fontSize * 1.5)}" ` +
`font-family="sans-serif" font-size="${fontSize}" fill="#ffffff" ` +
`fill-opacity="${(config.opacity / 100).toFixed(3)}">${content}</text>` +
`</svg>`
);
}
/** 渲染并裁紧文字层(透明底 PNG)。空内容产生零宽层 → 调用方视为无水印。 */
async function renderTextLayer(config: StoredWatermarkConfig): Promise<Buffer> {
const png = await sharp(Buffer.from(textSvg(config), 'utf8'))
.png()
.toBuffer();
return sharp(png).trim().toBuffer();
}
/** composite 左上角偏移(position + 边距;层尺寸可能超过基线时收钳)。 */
function layerOffset(
position: WatermarkPosition,
baseWidth: number,
baseHeight: number,
layerWidth: number,
layerHeight: number,
): { left: number; top: number } {
const margin = Math.max(
MIN_MARGIN_PX,
Math.round(baseWidth * MARGIN_RATIO),
);
const clamp = (value: number, max: number): number =>
Math.max(0, Math.min(value, max));
const right = baseWidth - layerWidth - margin;
const bottom = baseHeight - layerHeight - margin;
switch (position) {
case 'topLeft':
return { left: clamp(margin, baseWidth), top: clamp(margin, baseHeight) };
case 'topRight':
return { left: clamp(right, baseWidth), top: clamp(margin, baseHeight) };
case 'bottomLeft':
return {
left: clamp(margin, baseWidth),
top: clamp(bottom, baseHeight),
};
case 'bottomRight':
return { left: clamp(right, baseWidth), top: clamp(bottom, baseHeight) };
case 'center':
return {
left: clamp(Math.round((baseWidth - layerWidth) / 2), baseWidth),
top: clamp(Math.round((baseHeight - layerHeight) / 2), baseHeight),
};
}
}
export interface WatermarkComposeResult {
buffer: Buffer;
width: number;
height: number;
}
/**
* 原图 → 带水印浏览图(发布路径与历史重生成共用)。基线与 pipeline.ts 的
* browse 变体同参(rotate + ≤1920 withoutEnlargement + JPEG q82),仅多一层
* 文字合成——与 browse 变体的字节差异即水印本身(e2e 断言依据)。
* enabled=false 或空文字层:不合成,直接产出与 browse 同参的干净图(调用方
* 负责「关闭 = 不产变体」的语义,此返回仅防御性兜底)。
*/
export async function composeWatermarkBrowse(
input: Buffer,
config: StoredWatermarkConfig,
): Promise<WatermarkComposeResult> {
// 目标尺寸解析析计算(同 pipeline.ts 的 photos.width/height 口径):sharp 的
// .metadata() 返回**输入**尺寸而非 resize 后尺寸,管线实例上取 meta 会拿到
// 缩放前值;withoutEnlargement 下 outW = min(w, 1920) 精确,outH 按比例取整
// (±1px 对层偏移无影响——边距/收钳兜住;精确尺寸以最终输出 metadata 为准)。
const meta = await sharp(input).metadata();
const swap = (meta.orientation ?? 1) >= 5;
const srcWidth = (swap ? meta.height : meta.width) ?? 0;
const srcHeight = (swap ? meta.width : meta.height) ?? 0;
if (srcWidth <= 0 || srcHeight <= 0) {
throw new Error('watermark: 无法解析原图尺寸');
}
const baseWidth = Math.min(srcWidth, BROWSE_MAX_WIDTH);
const baseHeight = Math.max(1, Math.round((srcHeight * baseWidth) / srcWidth));
let pipeline = sharp(input)
.rotate()
.resize({ width: BROWSE_MAX_WIDTH, withoutEnlargement: true });
if (config.enabled && config.text.content !== '') {
const nominal = await renderTextLayer(config);
const nominalMeta = await sharp(nominal).metadata();
const nominalWidth = nominalMeta.width ?? 0;
const nominalHeight = nominalMeta.height ?? 0;
if (nominalWidth > 0 && nominalHeight > 0) {
// 目标宽度 = 浏览图宽 × scalePercent%(min 1px;层不过基线——scalePercent
// 上限 50%,只剩边距空间,天然满足)。
const targetWidth = Math.max(
1,
Math.min(
Math.round((baseWidth * config.scalePercent) / 100),
baseWidth,
),
);
const layer = await sharp(nominal)
.resize({ width: targetWidth })
.png()
.toBuffer();
const layerMeta = await sharp(layer).metadata();
const { left, top } = layerOffset(
config.position,
baseWidth,
baseHeight,
layerMeta.width ?? targetWidth,
layerMeta.height ?? 1,
);
pipeline = pipeline.composite([{ input: layer, left, top }]);
}
}
const buffer = await pipeline.jpeg({ quality: 82 }).toBuffer();
const outMeta = await sharp(buffer).metadata();
return {
buffer,
width: outMeta.width ?? baseWidth,
height: outMeta.height ?? baseHeight,
};
}