feat: 增加水印
This commit is contained in:
@@ -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 补充说明
|
||||||
|
|
||||||
|
## 影响面
|
||||||
|
仅生成图被处理;参考图/上传图不动。水印合成失败自动跳过不阻断生成。开关关闭时生成的图保持干净,导出即所见。
|
||||||
@@ -56,9 +56,10 @@ Chrome 插件(WXT + React + antd) Python 后端(FastAPI,无数
|
|||||||
- `generator.py`:图像 provider(图生图,参考图 = 采集主图)
|
- `generator.py`:图像 provider(图生图,参考图 = 采集主图)
|
||||||
- `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`)
|
- `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`)
|
||||||
- `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`,wan* 异步轮询 / qwen* 同步)
|
- `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`
|
- ⚠️ `gpt-image-2-vip` 为官逆通道:不透传保真参数、参考图被弱化,商品还原度不稳定(生成前有警示);正式出图用 `gpt-image-2`
|
||||||
- 插件只传模型名,服务端按模型名自动路由到对应 provider
|
- 插件只传模型名,服务端按模型名自动路由到对应 provider
|
||||||
|
- `watermark.py`:生成图水印(Pillow 后处理,AI 出图后、落盘前合成;插件「服务端设置」里开关,默认样式复刻 ozonSeller:图片圆形徽章 / 文字白字黑描边,右下角;预览与导出即所见)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const App: React.FC = () => {
|
|||||||
const uploadSeqRef = useRef(0);
|
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');
|
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
|
||||||
@@ -368,7 +368,9 @@ const App: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const payload = buildGeneratePayload(
|
const payload = buildGeneratePayload(
|
||||||
allImages, selectedKeys, editedTexts(),
|
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', {
|
const { suite_id } = await send<{ suite_id: string }>('generateSuite', {
|
||||||
baseUrl: settings.baseUrl, token: settings.token, payload,
|
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); }
|
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 = (
|
const settingsPopup = (
|
||||||
<div style={{ width: 260 }}>
|
<div style={{ width: 300 }}>
|
||||||
<Field label="后端地址">
|
<Field label="后端地址">
|
||||||
<input
|
<input
|
||||||
value={settings.baseUrl}
|
value={settings.baseUrl}
|
||||||
@@ -619,6 +628,44 @@ const App: React.FC = () => {
|
|||||||
onBlur={() => saveSettings(settings)}
|
onBlur={() => saveSettings(settings)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ function authHeaders(token: string): Record<string, string> {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions) */
|
||||||
|
export interface WatermarkPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
type: 'image' | 'text';
|
||||||
|
text: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
|
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
|
||||||
export interface GeneratePayload {
|
export interface GeneratePayload {
|
||||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||||
@@ -166,6 +174,7 @@ export interface GeneratePayload {
|
|||||||
plan: PlanItem[];
|
plan: PlanItem[];
|
||||||
platform: string;
|
platform: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
|
watermark?: WatermarkPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
|
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
|
||||||
@@ -173,7 +182,7 @@ export function buildGeneratePayload(
|
|||||||
images: ImageMaterial[],
|
images: ImageMaterial[],
|
||||||
selectedKeys: Set<string>,
|
selectedKeys: Set<string>,
|
||||||
texts: GeneratePayload['texts'],
|
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 {
|
): GeneratePayload {
|
||||||
const selected = images
|
const selected = images
|
||||||
.filter((img) => selectedKeys.has(img.key))
|
.filter((img) => selectedKeys.has(img.key))
|
||||||
|
|||||||
@@ -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 {
|
export interface BackendSettings {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
watermark: WatermarkSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = 'suite_backend_settings';
|
const KEY = 'suite_backend_settings';
|
||||||
@@ -13,6 +23,7 @@ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
|
|||||||
const DEFAULT: BackendSettings = {
|
const DEFAULT: BackendSettings = {
|
||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
token: '',
|
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 r = await chrome.storage.local.get(KEY);
|
||||||
const saved = r[KEY] ?? {};
|
const saved = r[KEY] ?? {};
|
||||||
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
|
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 }); // 迁移结果写回
|
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async def generate_suite(
|
|||||||
total=len(jobs),
|
total=len(jobs),
|
||||||
context=texts_to_raw(req.texts),
|
context=texts_to_raw(req.texts),
|
||||||
plan=jobs,
|
plan=jobs,
|
||||||
|
watermark=req.watermark.model_dump() if req.watermark else None,
|
||||||
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
||||||
ref_images=[
|
ref_images=[
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -9,6 +9,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# 仓库根(server/ 的上一级)
|
# 仓库根(server/ 的上一级)
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# 水印徽章图(复刻 ozonSeller 图表处理的默认水印;请求可选图片/文字水印)
|
||||||
|
WATERMARK_IMAGE_PATH = Path(__file__).resolve().parent / "assets" / "watermark.jpg"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -26,6 +29,9 @@ class Settings(BaseSettings):
|
|||||||
# ── 存储 ──
|
# ── 存储 ──
|
||||||
data_dir: str = str(ROOT / "data")
|
data_dir: str = str(ROOT / "data")
|
||||||
|
|
||||||
|
# 水印图片路径(图片水印的徽章源图,可用 .env 覆盖)
|
||||||
|
watermark_image_path: str = str(WATERMARK_IMAGE_PATH)
|
||||||
|
|
||||||
# ── 图像生成 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)──
|
# ── 图像生成 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)──
|
||||||
image_provider: str = "doubao"
|
image_provider: str = "doubao"
|
||||||
request_timeout: int = 300 # 单张生图请求超时(秒)
|
request_timeout: int = 300 # 单张生图请求超时(秒)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pydantic>=2.6
|
|||||||
pydantic-settings>=2.2
|
pydantic-settings>=2.2
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
pillow>=10.0
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Pydantic 契约(插件 ↔ 服务端)。"""
|
"""Pydantic 契约(插件 ↔ 服务端)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
SUPPORTED_TYPES = [
|
SUPPORTED_TYPES = [
|
||||||
@@ -62,6 +64,17 @@ class GenerateImageItem(BaseModel):
|
|||||||
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
|
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):
|
class PlanItem(BaseModel):
|
||||||
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
||||||
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
||||||
@@ -83,6 +96,7 @@ class GenerateRequest(BaseModel):
|
|||||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
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)")
|
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)──
|
# ── 出图方案规划(DeepSeek)──
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from config import get_settings
|
|||||||
from services import storage
|
from services import storage
|
||||||
from services.prompts import build_prompt, build_context, type_name
|
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.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")
|
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)
|
refs = _refs_for_job(list(task.ref_images or []), job)
|
||||||
data = await generator(prompt, refs, size=size, model=model)
|
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),按魔数定扩展名
|
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
|
||||||
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
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)
|
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Task:
|
|||||||
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
|
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
|
||||||
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
|
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
|
||||||
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
|
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
|
||||||
|
watermark: dict | None = None # 水印选项(落盘前服务端后处理)
|
||||||
|
|
||||||
|
|
||||||
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
|
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user