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

115 lines
4.8 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.
// packages/contracts/src/watermark.ts —— 水印配置的跨服务共享形状与缺省值
// (P4a Task 3)。三个消费方经同一模块引用,杜绝形状漂移:
// · api albums 域(POST watermark-jobs:DTO 校验 → normalize 落库);
// · worker 重生成消费者(读 watermark_configs.config Json → 本形状使用);
// · worker 发布路径(新照片按相册当前配置即产水印变体)。
//
// 版本语义(本文件是权威表述,实现侧注释引用):
// · albums.watermarkVersion = 最新「已创建」配置版本——POST 即推进,新照片
// 发布立刻套用(契约 WatermarkJobCreated.configVersion 描述:新照片即刻套用);
// · albums.watermarkEnabled + watermarkEffectiveVersion = 「有效」版本——仅当该
// 版本的历史重生成任务 done(或创建时相册无已发布照片、任务即建即 done)才
// 推进。ov 副本与公开侧的模式键/变体选择以有效版本为准:对象键
// `variants/{photoId}/{version}/watermark.jpg` 只有 done 任务保证存在,
// 用有效版本杜绝「同步了新版本号但对象尚未生成」的破图窗口(A20:
// 任务完成前旧浏览图保持可用,失败照片保留旧图)。
// · enabled=false 也是一版配置(记录关闭动作):关闭后发布的新照片无水印;
// 关闭版重生成任务 = 逐张删除 watermark 变体行(对象保留,历史可溯)。
export const WATERMARK_POSITIONS = [
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
'center',
] as const;
export type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number];
// ── 服务端缺省值(契约 WatermarkConfig「可选字段缺省用服务端默认值」的唯一权威)──
export const WATERMARK_DEFAULT_POSITION: WatermarkPosition = 'bottomRight';
export const WATERMARK_DEFAULT_OPACITY = 50;
export const WATERMARK_DEFAULT_SCALE_PERCENT = 20;
export const WATERMARK_DEFAULT_FONT_SIZE = 24;
/**
* 归一化后的存储形状(watermark_configs.config)。API 保存时经
* normalizeWatermarkConfig 填齐缺省值落库——worker 与 GET 读到的永远是完整
* 配置,不再各自解释缺省。enabled=false 时 text.content 允许为空串
* (关闭语义不绘制文字,其余字段仅作回显)。
*/
export interface StoredWatermarkConfig {
enabled: boolean;
position: WatermarkPosition;
/** 不透明度百分比 0..100。 */
opacity: number;
/** 水印层宽度相对浏览图宽度的百分比 5..50。 */
scalePercent: number;
text: {
content: string;
/** 名义字号 8..72(渲染后再按 scalePercent 缩放,见 worker 实现)。 */
fontSize: number;
};
}
/** 契约 WatermarkConfig(创建请求 / 当前配置响应)。 */
export type WatermarkConfigInput = {
enabled: boolean;
position?: WatermarkPosition;
opacity?: number;
scalePercent?: number;
text?: { content?: string; fontSize?: number };
};
/** 缺省值填齐(不改写已给值;越界值由 API DTO 层 400 拒绝,此处不重复校验)。 */
export function normalizeWatermarkConfig(
raw: WatermarkConfigInput,
): StoredWatermarkConfig {
return {
enabled: raw.enabled,
position: raw.position ?? WATERMARK_DEFAULT_POSITION,
opacity: raw.opacity ?? WATERMARK_DEFAULT_OPACITY,
scalePercent: raw.scalePercent ?? WATERMARK_DEFAULT_SCALE_PERCENT,
text: {
content: raw.text?.content ?? '',
fontSize: raw.text?.fontSize ?? WATERMARK_DEFAULT_FONT_SIZE,
},
};
}
/**
* 读取侧解析(watermark_configs.config Json → StoredWatermarkConfig):行由
* normalizeWatermarkConfig 写入、形状可信,此解析仅对脏数据防御——缺字段回
* 落缺省值,结构破坏(非对象 / enabled 非 boolean)返回 null(调用方按
* 「配置不可用」处理:重生成任务整败 / 公开侧回落无水印)。
*/
export function parseStoredWatermarkConfig(
value: unknown,
): StoredWatermarkConfig | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const raw = value as Partial<WatermarkConfigInput> & {
text?: Partial<WatermarkConfigInput['text']>;
};
if (typeof raw.enabled !== 'boolean') {
return null;
}
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
const str = (v: unknown): string | undefined =>
typeof v === 'string' ? v : undefined;
return normalizeWatermarkConfig({
enabled: raw.enabled,
position:
raw.position !== undefined &&
(WATERMARK_POSITIONS as readonly string[]).includes(raw.position)
? raw.position
: undefined,
opacity: num(raw.opacity),
scalePercent: num(raw.scalePercent),
text: {
content: str(raw.text?.content) ?? '',
fontSize: num(raw.text?.fontSize),
},
});
}