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 0000000..af3f3bd Binary files /dev/null and b/server/assets/watermark.jpg differ diff --git a/server/config.py b/server/config.py index 5bddc6a..5fa3fe7 100644 --- a/server/config.py +++ b/server/config.py @@ -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) + # ── 图像生成 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)── image_provider: str = "doubao" request_timeout: int = 300 # 单张生图请求超时(秒) diff --git a/server/requirements.txt b/server/requirements.txt index 8efcfab..3701dd3 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -4,3 +4,4 @@ pydantic>=2.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