From 024777ed8a108d4d6484097847077108e97d77b3 Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 24 Aug 2026 17:15:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=B0=B4=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_09baa2fe-f66c-4c4c-a76b-38813a98e415.md | 41 ++++++ README.md | 3 +- extension/entrypoints/sidepanel/App.tsx | 53 +++++++- extension/src/api/client.ts | 11 +- extension/src/storage/settings.ts | 21 ++- server/api/generate.py | 1 + server/assets/watermark.jpg | Bin 0 -> 14372 bytes server/config.py | 6 + server/requirements.txt | 1 + server/schemas.py | 14 ++ server/services/generator.py | 5 + server/services/tasks.py | 1 + server/services/watermark.py | 122 ++++++++++++++++++ 13 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 .zcode/plans/plan-sess_09baa2fe-f66c-4c4c-a76b-38813a98e415.md create mode 100644 server/assets/watermark.jpg create mode 100644 server/services/watermark.py diff --git a/.zcode/plans/plan-sess_09baa2fe-f66c-4c4c-a76b-38813a98e415.md b/.zcode/plans/plan-sess_09baa2fe-f66c-4c4c-a76b-38813a98e415.md new file mode 100644 index 0000000..40c1c16 --- /dev/null +++ b/.zcode/plans/plan-sess_09baa2fe-f66c-4c4c-a76b-38813a98e415.md @@ -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. **UI(App.tsx settingsPopup)** + - 加水印设置块:开启 checkbox、类型 pills(图片/文字)、文字内容 input(仅文字类型显示)、透明度 number(0–100 步进 5,带 %);Popover 宽度 260→300 + - `startGenerate` 的 config 在开启时带 `watermark`,关闭不下发 + +## 验证 +- 烟测:对已有生成图字节分别跑图片/文字两种水印(含透明度边界),人工查看合成效果 +- 前端 `tsc --noEmit`;README 补充说明 + +## 影响面 +仅生成图被处理;参考图/上传图不动。水印合成失败自动跳过不阻断生成。开关关闭时生成的图保持干净,导出即所见。 \ No newline at end of file diff --git a/README.md b/README.md index 6e72a21..48acb9a 100644 --- a/README.md +++ b/README.md @@ -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-pro(OpenAI 兼容中转 `RIGHTAPI_API_KEY`,edits 参考图 / generations 同步) + - `rightapi`:gpt-image-2 / gpt-image-2-vip / nano-banana / nano-banana-2 / nano-banana-2-lite / nano-banana-pro(OpenAI 兼容中转 `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:图片圆形徽章 / 文字白字黑描边,右下角;预览与导出即所见) ## 快速开始 diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx index 9ca1151..e0bc1b4 100644 --- a/extension/entrypoints/sidepanel/App.tsx +++ b/extension/entrypoints/sidepanel/App.tsx @@ -137,7 +137,7 @@ const App: React.FC = () => { const uploadSeqRef = useRef(0); // 服务端 - const [settings, setSettings] = useState({ baseUrl: 'http://127.0.0.1:3300', token: '' }); + const [settings, setSettings] = useState({ 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) => { + const next = { ...settings, watermark: { ...settings.watermark, ...patch } }; + setSettings(next); + saveSettings(next); + }; + const settingsPopup = ( -
+
{ onBlur={() => saveSettings(settings)} /> +
+ + {settings.watermark.enabled && ( + <> +
+ +
+ patchWatermark({ type: 'image' })}>图片 + patchWatermark({ type: 'text' })}>文字 +
+
+ {settings.watermark.type === 'text' && ( + + setSettings({ ...settings, watermark: { ...settings.watermark, text: e.target.value } })} + onBlur={() => saveSettings(settings)} + /> + + )} + + setSettings({ ...settings, watermark: { ...settings.watermark, opacity: Number(e.target.value) || 1 } })} + onBlur={() => saveSettings(settings)} + /> + +
位置固定右下角;重新生成后生效,已生成的图不变
+ + )}
); diff --git a/extension/src/api/client.ts b/extension/src/api/client.ts index 923b2a2..7529379 100644 --- a/extension/src/api/client.ts +++ b/extension/src/api/client.ts @@ -156,6 +156,14 @@ function authHeaders(token: string): Record { 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, 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)) diff --git a/extension/src/storage/settings.ts b/extension/src/storage/settings.ts index c7eb6e2..2ba5f60 100644 --- a/extension/src/storage/settings.ts +++ b/extension/src/storage/settings.ts @@ -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 { 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; } diff --git a/server/api/generate.py b/server/api/generate.py index 2224aa3..064fd42 100644 --- a/server/api/generate.py +++ b/server/api/generate.py @@ -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=[ { diff --git a/server/assets/watermark.jpg b/server/assets/watermark.jpg new file mode 100644 index 0000000000000000000000000000000000000000..af3f3bdfc2895af0f6fc1556129da040e15ae232 GIT binary patch literal 14372 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.6 pydantic-settings>=2.2 httpx>=0.27 python-multipart>=0.0.9 +pillow>=10.0 diff --git a/server/schemas.py b/server/schemas.py index 7bd03ba..f49bf46 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -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="覆盖默认 provider(doubao | 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)── diff --git a/server/services/generator.py b/server/services/generator.py index adba373..ad763fa 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -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) diff --git a/server/services/tasks.py b/server/services/tasks.py index 287db14..f6fee8e 100644 --- a/server/services/tasks.py +++ b/server/services/tasks.py @@ -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 单事件循环读写,无并发问题;不做淘汰(单会话量级很小) diff --git a/server/services/watermark.py b/server/services/watermark.py new file mode 100644 index 0000000..1eb1bde --- /dev/null +++ b/server/services/watermark.py @@ -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