feat: 增加水印

This commit is contained in:
R524809
2026-08-24 17:15:08 +08:00
parent 95cb93a160
commit 024777ed8a
13 changed files with 272 additions and 7 deletions
@@ -0,0 +1,41 @@
# 水印功能实现计划(服务端后处理合成,非 AI 模型加水印)
## 架构决策
- 水印在 **AI 出图返回后、落盘前** 由服务端合成(`run_suite``generator()` 返回字节之后、`storage.write_bytes` 之前)。预览(/media URL)与导出 ZIP 自然都是带水印图,所见即所得;不经过提示词/AI 模型。
- 开关与配置放插件"服务端设置"Popover,随生成请求下发(服务端不做 .env 配置项);持久化在 chrome.storage.local。
- 默认样式复刻 ozonSeller「图表处理」:图片水印 = `imgs/watermark.jpg` 圆形徽章(宽 15%、右下、透明度 30%);文字水印 = 默认文案 `xiongmaoyx`(白字黑描边、字号 6% 宽度、加粗)。位置本期固定右下,不做调节。
## 服务端(server/
1. **依赖与资产**
- `requirements.txt``pillow` 并安装(venv 现无 PIL
- 复制 `ozon-seller-kit/web/imgs/watermark.jpg``server/assets/watermark.jpg`200×200 JPEG
- `config.py``watermark_image_path`(默认指向上述资产)
2. **新模块 `services/watermark.py`**
- `apply_watermark(data: bytes, opts: dict) -> bytes`:魔数识别 PNG/JPEG → PIL 打开转 RGBA 合成 → 按原格式保存(JPEG quality≈95
- 图片水印:资产中心裁方 → 圆形遮罩 → 缩放到图宽 15% → 按 opacity 合成 → 右下角贴入(边距 ≈1% 图宽)
- 文字水印:字号 = 图宽 6%(下限 12px)、白色填充 + 黑色描边(alpha 0.55、描边宽 fontSize/8);字体回退链 PingFang → Hiragino Sans GB → STHeiti → Pillow 默认(模块级缓存首次命中)
- 容错:字体/资产缺失时 log warning 并返回原图,绝不阻断生图
3. **协议与流转**
- `schemas.py`:新增 `WatermarkOptions``enabled=False, type='image'|'text', text='xiongmaoyx', opacity=30`),`GenerateRequest``watermark` 字段
- `tasks.py``Task``watermark: dict | None`
- `api/generate.py``create_task` 透传
- `generator.py` `run_suite`:落盘前 `if watermark enabled: data = apply_watermark(...)`
## 插件端(extension/
4. **设置与请求**
- `src/storage/settings.ts``BackendSettings``watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 }`loadSettings 对该子对象做深合并(兼容老数据)
- `src/api/client.ts``GeneratePayload``watermark?``buildGeneratePayload` 透传
5. **UIApp.tsx settingsPopup**
- 加水印设置块:开启 checkbox、类型 pills(图片/文字)、文字内容 input(仅文字类型显示)、透明度 number(0–100 步进 5,带 %);Popover 宽度 260→300
- `startGenerate` 的 config 在开启时带 `watermark`,关闭不下发
## 验证
- 烟测:对已有生成图字节分别跑图片/文字两种水印(含透明度边界),人工查看合成效果
- 前端 `tsc --noEmit`README 补充说明
## 影响面
仅生成图被处理;参考图/上传图不动。水印合成失败自动跳过不阻断生成。开关关闭时生成的图保持干净,导出即所见。
+2 -1
View File
@@ -56,9 +56,10 @@ Chrome 插件(WXT + React + antd Python 后端(FastAPI,无数
- `generator.py`:图像 provider(图生图,参考图 = 采集主图)
- `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`
- `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`wan* 异步轮询 / qwen* 同步)
- `rightapi`gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-proOpenAI 兼容中转 `RIGHTAPI_API_KEY`edits 参考图 / generations 同步
- `rightapi`gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-proOpenAI 兼容中转 `RIGHTAPI_API_KEY`统一 `/v1/images/generations` 异步任务流,参考图走 JSON `image` data-URI 数组——见 `docs/rightapi-调用排查与修复方案.md`
- ⚠️ `gpt-image-2-vip` 为官逆通道:不透传保真参数、参考图被弱化,商品还原度不稳定(生成前有警示);正式出图用 `gpt-image-2`
- 插件只传模型名,服务端按模型名自动路由到对应 provider
- `watermark.py`:生成图水印(Pillow 后处理,AI 出图后、落盘前合成;插件「服务端设置」里开关,默认样式复刻 ozonSeller:图片圆形徽章 / 文字白字黑描边,右下角;预览与导出即所见)
## 快速开始
+50 -3
View File
@@ -137,7 +137,7 @@ const App: React.FC = () => {
const uploadSeqRef = useRef(0);
// 服务端
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '', watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 } });
// 出图方案
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
@@ -368,7 +368,9 @@ const App: React.FC = () => {
try {
const payload = buildGeneratePayload(
allImages, selectedKeys, editedTexts(),
{ style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model },
{ style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model,
// 水印开启才下发,关闭时不带(服务端按无水印处理)
watermark: settings.watermark.enabled ? settings.watermark : undefined },
);
const { suite_id } = await send<{ suite_id: string }>('generateSuite', {
baseUrl: settings.baseUrl, token: settings.token, payload,
@@ -603,8 +605,15 @@ const App: React.FC = () => {
else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); }
};
/** 更新水印子设置并落盘(弹窗里控件共用) */
const patchWatermark = (patch: Partial<BackendSettings['watermark']>) => {
const next = { ...settings, watermark: { ...settings.watermark, ...patch } };
setSettings(next);
saveSettings(next);
};
const settingsPopup = (
<div style={{ width: 260 }}>
<div style={{ width: 300 }}>
<Field label="后端地址">
<input
value={settings.baseUrl}
@@ -619,6 +628,44 @@ const App: React.FC = () => {
onBlur={() => saveSettings(settings)}
/>
</Field>
<div className="divider" style={{ margin: '10px 0' }} />
<label className="auto-chk" title="开启后,生成的图在落盘前由服务端合成水印(导出即带水印)">
<input
type="checkbox"
checked={settings.watermark.enabled}
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
/>
</label>
{settings.watermark.enabled && (
<>
<div className="field" style={{ marginTop: 8 }}>
<label></label>
<div className="pills">
<span className={`pill ${settings.watermark.type === 'image' ? 'on' : ''}`} onClick={() => patchWatermark({ type: 'image' })}></span>
<span className={`pill ${settings.watermark.type === 'text' ? 'on' : ''}`} onClick={() => patchWatermark({ type: 'text' })}></span>
</div>
</div>
{settings.watermark.type === 'text' && (
<Field label="水印文字">
<input
value={settings.watermark.text}
onChange={(e) => setSettings({ ...settings, watermark: { ...settings.watermark, text: e.target.value } })}
onBlur={() => saveSettings(settings)}
/>
</Field>
)}
<Field label="不透明度(%">
<input
type="number" min={1} max={100} step={5}
value={settings.watermark.opacity}
onChange={(e) => setSettings({ ...settings, watermark: { ...settings.watermark, opacity: Number(e.target.value) || 1 } })}
onBlur={() => saveSettings(settings)}
/>
</Field>
<div className="hint" style={{ marginTop: 4 }}></div>
</>
)}
</div>
);
+10 -1
View File
@@ -156,6 +156,14 @@ function authHeaders(token: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions */
export interface WatermarkPayload {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number;
}
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
export interface GeneratePayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
@@ -166,6 +174,7 @@ export interface GeneratePayload {
plan: PlanItem[];
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
}
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
@@ -173,7 +182,7 @@ export function buildGeneratePayload(
images: ImageMaterial[],
selectedKeys: Set<string>,
texts: GeneratePayload['texts'],
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null },
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null; watermark?: WatermarkPayload },
): GeneratePayload {
const selected = images
.filter((img) => selectedKeys.has(img.key))
+19 -2
View File
@@ -1,9 +1,19 @@
/**
* 服务端设置(上传/生成用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
* 服务端设置(上传/生成用):后端地址 + Bearer Token + 水印选项,持久化到 chrome.storage.local。
*/
/** 生成图水印:服务端在 AI 出图后、落盘前合成(与生图模型无关)。默认复刻 ozonSeller。 */
export interface WatermarkSettings {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number; // 1-100%
}
export interface BackendSettings {
baseUrl: string;
token: string;
watermark: WatermarkSettings;
}
const KEY = 'suite_backend_settings';
@@ -13,6 +23,7 @@ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
const DEFAULT: BackendSettings = {
baseUrl: DEFAULT_BASE_URL,
token: '',
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
};
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
@@ -30,7 +41,13 @@ export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
const saved = r[KEY] ?? {};
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
const s: BackendSettings = { token: '', ...saved, baseUrl };
// 水印子对象深合并:老版本存储里没有 watermark,避免整对象覆盖丢默认值
const s: BackendSettings = {
token: '',
...saved,
baseUrl,
watermark: { ...DEFAULT.watermark, ...(saved.watermark ?? {}) },
};
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
return s;
}
+1
View File
@@ -103,6 +103,7 @@ async def generate_suite(
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
watermark=req.watermark.model_dump() if req.watermark else None,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+6
View File
@@ -9,6 +9,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# 仓库根(server/ 的上一级)
ROOT = Path(__file__).resolve().parents[1]
# 水印徽章图(复刻 ozonSeller 图表处理的默认水印;请求可选图片/文字水印)
WATERMARK_IMAGE_PATH = Path(__file__).resolve().parent / "assets" / "watermark.jpg"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -26,6 +29,9 @@ class Settings(BaseSettings):
# ── 存储 ──
data_dir: str = str(ROOT / "data")
# 水印图片路径(图片水印的徽章源图,可用 .env 覆盖)
watermark_image_path: str = str(WATERMARK_IMAGE_PATH)
# ── 图像生成 providerdoubao(火山方舟 Seedream| tongyi(阿里 DashScope)──
image_provider: str = "doubao"
request_timeout: int = 300 # 单张生图请求超时(秒)
+1
View File
@@ -4,3 +4,4 @@ pydantic>=2.6
pydantic-settings>=2.2
httpx>=0.27
python-multipart>=0.0.9
pillow>=10.0
+14
View File
@@ -1,6 +1,8 @@
"""Pydantic 契约(插件 ↔ 服务端)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
SUPPORTED_TYPES = [
@@ -62,6 +64,17 @@ class GenerateImageItem(BaseModel):
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
class WatermarkOptions(BaseModel):
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。
默认样式复刻 ozonSeller 图表处理:图片徽章 / 文字描边,右下角。
"""
enabled: bool = Field(default=False, description="是否开启水印")
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
text: str = Field(default="xiongmaoyx", description="文字水印内容")
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
@@ -83,6 +96,7 @@ class GenerateRequest(BaseModel):
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
provider: str | None = Field(default=None, description="覆盖默认 providerdoubao | tongyi")
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成)")
# ── 出图方案规划(DeepSeek)──
+5
View File
@@ -18,6 +18,7 @@ from config import get_settings
from services import storage
from services.prompts import build_prompt, build_context, type_name
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_FAILED, IMG_OK
from services.watermark import apply_watermark
log = logging.getLogger("suite.generator")
@@ -435,6 +436,10 @@ async def run_suite(task: Task) -> None:
)
refs = _refs_for_job(list(task.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model)
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
wm = task.watermark or {}
if wm.get("enabled"):
data = apply_watermark(data, wm)
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
+1
View File
@@ -53,6 +53,7 @@ class Task:
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
watermark: dict | None = None # 水印选项(落盘前服务端后处理)
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
+122
View File
@@ -0,0 +1,122 @@
"""生成图水印:AI 出图返回后、落盘前的后处理合成(不经过生图模型)。
样式复刻 ozonSeller「图表处理」的默认水印:
- 图片水印:徽章图中心裁方 → 圆形遮罩 → 宽度为图宽 15%,右下角,边距约 1% 图宽;
- 文字水印:字号为图宽 6%(下限 12px),白色填充 + 黑色描边(alpha 0.55
描边宽 fontSize/8),加粗无衬线。
容错原则:字体/资产缺失或合成异常时 log 警告并返回原图,绝不阻断生图。
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from config import get_settings
log = logging.getLogger("suite.watermark")
# 尺寸比例(与 ozonSeller app.js 常量一致)
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
TEXT_SCALE = 0.06 # 文字字号 / 图宽
TEXT_MIN_SIZE = 12
STROKE_ALPHA = 0.55
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
# CJK/西文字体回退链(macOS 本地服务);命中后模块级缓存
_FONT_CANDIDATES = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
_font_path: str | None = None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
global _font_path
if _font_path is None:
_font_path = next((p for p in _FONT_CANDIDATES if Path(p).is_file()), "")
if _font_path:
return ImageFont.truetype(_font_path, size)
log.warning("未找到系统字体(%s),文字水印退化为 Pillow 默认字体,中文可能乱码", _FONT_CANDIDATES)
return ImageFont.load_default(size) if size >= 10 else ImageFont.load_default()
def _circular_badge(size: int) -> Image.Image | None:
"""徽章资产 → 指定直径的圆形 RGBA 贴片;资产缺失返回 None。"""
path = get_settings().watermark_image_path
try:
badge = Image.open(path).convert("RGBA")
except Exception as exc: # noqa: BLE001
log.warning("水印图片加载失败(%s),跳过图片水印: %s", path, exc)
return None
side = min(badge.size) # 中心裁方
left, top = (badge.width - side) // 2, (badge.height - side) // 2
square = badge.crop((left, top, left + side, top + side)).resize((size, size))
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
square.putalpha(mask)
return square
def _apply_image_watermark(canvas: Image.Image, opacity: float) -> None:
size = max(24, round(canvas.width * BADGE_SCALE))
badge = _circular_badge(size)
if badge is None:
return
badge.putalpha(badge.getchannel("A").point(lambda a: round(a * opacity)))
margin = max(10, round(canvas.width * BADGE_MARGIN))
canvas.alpha_composite(badge, (canvas.width - size - margin, canvas.height - size - margin))
def _apply_text_watermark(canvas: Image.Image, text: str, opacity: float) -> None:
text = (text or "").strip()
if not text:
return
font_size = max(TEXT_MIN_SIZE, round(canvas.width * TEXT_SCALE))
font = _load_font(font_size)
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
if tw >= canvas.width: # 文案比图还宽:按比例缩字号重排一次
font_size = max(TEXT_MIN_SIZE, round(font_size * canvas.width / tw * 0.94))
font = _load_font(font_size)
bbox = draw.textbbox((0, 0), text, font=font, stroke_width=max(1, round(font_size * STROKE_RATIO)))
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
margin = max(10, round(canvas.width * BADGE_MARGIN))
x = canvas.width - tw - margin - bbox[0]
y = canvas.height - th - margin - bbox[1]
stroke = (0, 0, 0, round(255 * STROKE_ALPHA))
fill = (255, 255, 255, 255)
draw.text((x, y), text, font=font, fill=fill, stroke_width=max(1, round(font_size * STROKE_RATIO)),
stroke_fill=stroke)
layer.putalpha(layer.getchannel("A").point(lambda a: round(a * opacity)))
canvas.alpha_composite(layer)
def apply_watermark(data: bytes, opts: dict) -> bytes:
"""给图片字节加水印,返回同格式字节;opts: {type, text, opacity(0-100)}。"""
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
fmt = "PNG" if is_png else "JPEG"
try:
img = Image.open(io.BytesIO(data))
canvas = img.convert("RGBA")
opacity = min(100, max(1, int(opts.get("opacity") or 30))) / 100
if opts.get("type") == "text":
_apply_text_watermark(canvas, opts.get("text") or "", opacity)
else:
_apply_image_watermark(canvas, opacity)
out = io.BytesIO()
if fmt == "PNG":
canvas.save(out, format="PNG")
else:
canvas.convert("RGB").save(out, format="JPEG", quality=95)
return out.getvalue()
except Exception: # noqa: BLE001
log.exception("水印合成失败,返回原图")
return data