427 lines
15 KiB
Python
427 lines
15 KiB
Python
"""Pure product-suite configuration and generation planning helpers."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import OrderedDict
|
||
import re
|
||
|
||
|
||
FIXED_CATEGORIES = ("白底图", "场景图", "模特场景图", "细节说明图", "卖点图")
|
||
FIXED_CATEGORY_HELPERS = {
|
||
"白底图": "白底主图,多角度呈现商品细节",
|
||
"场景图": "生活化场景展示商品使用方式",
|
||
"模特场景图": "模特或手持场景展示商品真实使用",
|
||
"细节说明图": "突出材质、做工和关键细节",
|
||
"卖点图": "突出核心卖点和差异化优势",
|
||
}
|
||
FIXED_CATEGORY_DESCRIPTIONS = {
|
||
"白底图": "生成 Shopee 台灣商品白底主圖,純白背景,商品清晰置中,不添加多餘文字。",
|
||
"场景图": "生成生活化使用場景圖,展示商品在真實情境中的用途與氛圍,畫面自然可信。",
|
||
"模特场景图": "生成模特或手持使用情境圖,畫面自然可信,商品為主角。",
|
||
"细节说明图": "生成商品細節特寫說明圖,突出材質、做工、接口、紋理或關鍵細節。",
|
||
"卖点图": "生成賣點詳解圖,使用繁體中文短文案呈現核心優勢,版面乾淨。",
|
||
}
|
||
DEFAULT_CATEGORY_COUNTS = OrderedDict(
|
||
(
|
||
("白底图", 1),
|
||
("场景图", 2),
|
||
("模特场景图", 0),
|
||
("细节说明图", 0),
|
||
("卖点图", 2),
|
||
)
|
||
)
|
||
PLATFORMS = ("Shopee", "Lazada", "TikTok Shop", "Amazon")
|
||
COUNTRIES = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南")
|
||
LANGUAGES = ("繁体中文", "简体中文", "英文", "泰文", "越南文")
|
||
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
|
||
LAST_SETTING_KEYS = ("platform", "country", "language", "ratio")
|
||
MAX_CATEGORY_NAME_LENGTH = 10
|
||
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
|
||
PRODUCT_SUITE_PLACEHOLDERS = (
|
||
"生成目标",
|
||
"套图名称",
|
||
"补充描述",
|
||
"平台",
|
||
"国家地区",
|
||
"输出语言",
|
||
"图片比例",
|
||
"商品ID",
|
||
"主参考图序号",
|
||
"参考图规则",
|
||
"商品卖点与要求",
|
||
"尺寸与长图规则",
|
||
"禁用内容规则",
|
||
"价格信息规则",
|
||
"尺码信息规则",
|
||
)
|
||
PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS = (
|
||
"图片比例",
|
||
"参考图规则",
|
||
"商品卖点与要求",
|
||
"尺寸与长图规则",
|
||
"禁用内容规则",
|
||
"价格信息规则",
|
||
"尺码信息规则",
|
||
)
|
||
PRODUCT_SUITE_REQUIRED_PLACEHOLDERS = (
|
||
"生成目标",
|
||
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
|
||
)
|
||
PRODUCT_SUITE_LEGACY_REQUIRED_PLACEHOLDERS = (
|
||
"套图名称",
|
||
"补充描述",
|
||
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
|
||
)
|
||
PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS = (
|
||
"尺寸与长图规则",
|
||
"禁用内容规则",
|
||
"价格信息规则",
|
||
"尺码信息规则",
|
||
)
|
||
PRODUCT_SUITE_SIZE_RULE = (
|
||
"重要尺寸要求:最终输出必须严格符合所选比例的单张完整构图电商图,"
|
||
"禁止海报长图、详情页长图和多宫格拼接版面。"
|
||
)
|
||
PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE = (
|
||
"重要禁用内容:禁止在画面中出现任何国旗、旗帜、国徽、地图轮廓、"
|
||
"政治符号或类似国家/地区标识。"
|
||
)
|
||
PRODUCT_SUITE_PRICE_RULE = (
|
||
"价格信息规则:除非用户明确提供价格、折扣或活动价,否则禁止自行添加"
|
||
"价格、币别符号、折扣数字或促销金额。"
|
||
)
|
||
PRODUCT_SUITE_SIZE_INFO_RULE = (
|
||
"尺码信息规则:除非用户或参考图明确提供尺码、尺寸或规格,否则禁止自行"
|
||
"编造尺码、尺寸、适用身高体重等内容。"
|
||
)
|
||
_PRODUCT_SUITE_PLACEHOLDER_RE = re.compile(r"\{([^{}\r\n]+)\}")
|
||
|
||
|
||
class ProductSuitePromptError(ValueError):
|
||
"""Raised when a product-suite prompt template is invalid."""
|
||
|
||
|
||
def default_suite_settings():
|
||
return {
|
||
"platform": "Shopee",
|
||
"country": "中国台湾",
|
||
"language": "繁体中文",
|
||
"ratio": "1:1",
|
||
"per_image_primary": False,
|
||
"categories": dict(DEFAULT_CATEGORY_COUNTS),
|
||
"custom_category_order": [],
|
||
}
|
||
|
||
|
||
def normalize_suite_settings(value=None):
|
||
raw = dict(value or {}) if isinstance(value, dict) else {}
|
||
normalized = default_suite_settings()
|
||
normalized["platform"] = _choice(raw.get("platform"), PLATFORMS, "Shopee")
|
||
normalized["country"] = _choice(raw.get("country"), COUNTRIES, "中国台湾")
|
||
normalized["language"] = _choice(raw.get("language"), LANGUAGES, "繁体中文")
|
||
normalized["ratio"] = _choice(raw.get("ratio"), RATIOS, "1:1")
|
||
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
|
||
|
||
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
|
||
categories = OrderedDict()
|
||
for name, default_count in DEFAULT_CATEGORY_COUNTS.items():
|
||
categories[name] = _count(raw_categories.get(name, default_count))
|
||
requested_order = raw.get("custom_category_order")
|
||
requested_order = requested_order if isinstance(requested_order, list) else []
|
||
seen = set(FIXED_CATEGORIES)
|
||
custom_order = []
|
||
for candidate in list(requested_order) + list(raw_categories):
|
||
name = str(candidate or "")
|
||
if name in seen or suite_name_error(name, categories):
|
||
continue
|
||
seen.add(name)
|
||
custom_order.append(name)
|
||
categories[name] = _count(raw_categories.get(name, 1))
|
||
normalized["categories"] = dict(categories)
|
||
normalized["custom_category_order"] = custom_order
|
||
return normalized
|
||
|
||
|
||
def last_suite_settings(value=None):
|
||
normalized = normalize_suite_settings(value)
|
||
return {key: normalized[key] for key in LAST_SETTING_KEYS}
|
||
|
||
|
||
def suite_name_error(name, existing=None, old_name=""):
|
||
value = str(name or "")
|
||
if not value.strip():
|
||
return "分类名称不能为空"
|
||
if value != value.strip() or any(character.isspace() for character in value):
|
||
return "分类名称不能包含空格"
|
||
if len(value) > MAX_CATEGORY_NAME_LENGTH:
|
||
return "分类名称不能超过10个字"
|
||
names = set(existing or ())
|
||
if value in names and value != str(old_name or ""):
|
||
return "分类名称已存在"
|
||
return ""
|
||
|
||
|
||
def category_order(settings):
|
||
normalized = normalize_suite_settings(settings)
|
||
categories = normalized["categories"]
|
||
custom = [
|
||
name
|
||
for name in normalized["custom_category_order"]
|
||
if name in categories and name not in FIXED_CATEGORIES
|
||
]
|
||
return list(FIXED_CATEGORIES) + custom
|
||
|
||
|
||
def category_helper(category):
|
||
return FIXED_CATEGORY_HELPERS.get(str(category or ""), "")
|
||
|
||
|
||
def category_description(category):
|
||
name = str(category or "").strip()
|
||
fixed_description = FIXED_CATEGORY_DESCRIPTIONS.get(name)
|
||
if fixed_description:
|
||
return fixed_description
|
||
if name:
|
||
return "生成自定义分类图片:%s。" % name
|
||
return "生成自定义分类图片。"
|
||
|
||
|
||
def suite_total_count(settings, image_count):
|
||
normalized = normalize_suite_settings(settings)
|
||
categories = normalized["categories"]
|
||
base = sum(_count(categories.get(name, 0)) for name in category_order(normalized))
|
||
if not normalized["per_image_primary"]:
|
||
return base
|
||
white_count = _count(categories.get("白底图", 0))
|
||
other_count = max(0, base - white_count)
|
||
return white_count + other_count * max(1, int(image_count or 0))
|
||
|
||
|
||
def product_suite_prompt_errors(template_text):
|
||
text = str(template_text or "")
|
||
errors = []
|
||
if not text.strip():
|
||
return ["套图提示词模板不能为空"]
|
||
|
||
matches = list(_PRODUCT_SUITE_PLACEHOLDER_RE.finditer(text))
|
||
remainder = _PRODUCT_SUITE_PLACEHOLDER_RE.sub("", text)
|
||
if "{" in remainder or "}" in remainder:
|
||
errors.append("模板包含未闭合花括号或不支持的字面花括号")
|
||
|
||
names = [match.group(1) for match in matches]
|
||
unknown = sorted(set(names) - set(PRODUCT_SUITE_PLACEHOLDERS))
|
||
if unknown:
|
||
errors.append("模板包含未知变量:%s" % "、".join("{%s}" % name for name in unknown))
|
||
|
||
missing = [
|
||
name
|
||
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
|
||
if name not in names
|
||
]
|
||
if missing:
|
||
errors.append("模板缺少必需变量:%s" % "、".join("{%s}" % name for name in missing))
|
||
has_new_target = "生成目标" in names
|
||
has_legacy_target = all(
|
||
name in names for name in ("套图名称", "补充描述")
|
||
)
|
||
if not has_new_target and not has_legacy_target:
|
||
errors.append(
|
||
"模板缺少必需变量:{生成目标}(旧模板需同时包含{套图名称}和{补充描述})"
|
||
)
|
||
|
||
invalid_rule_lines = []
|
||
for line in text.splitlines():
|
||
line_names = _PRODUCT_SUITE_PLACEHOLDER_RE.findall(line)
|
||
for name in line_names:
|
||
if (
|
||
name in PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS
|
||
and line.strip() != "{%s}" % name
|
||
):
|
||
invalid_rule_lines.append(name)
|
||
if invalid_rule_lines:
|
||
errors.append(
|
||
"只读规则变量必须独占一行:%s"
|
||
% "、".join("{%s}" % name for name in sorted(set(invalid_rule_lines)))
|
||
)
|
||
return errors
|
||
|
||
|
||
def validate_product_suite_prompt(template_text):
|
||
errors = product_suite_prompt_errors(template_text)
|
||
if errors:
|
||
raise ProductSuitePromptError(";".join(errors))
|
||
return str(template_text)
|
||
|
||
|
||
def reference_asset_count(settings, source_image_count):
|
||
"""Return the effective reference-image count for a planned cmhub request."""
|
||
normalized = normalize_suite_settings(settings)
|
||
if normalized["per_image_primary"]:
|
||
return 0
|
||
try:
|
||
image_count = max(0, int(source_image_count or 0))
|
||
except (TypeError, ValueError):
|
||
image_count = 0
|
||
return min(7, max(0, image_count - 1))
|
||
|
||
|
||
def product_suite_reference_rule(settings, reference_asset_count=0):
|
||
normalized = normalize_suite_settings(settings)
|
||
try:
|
||
count = max(0, min(7, int(reference_asset_count or 0)))
|
||
except (TypeError, ValueError):
|
||
count = 0
|
||
if normalized["per_image_primary"] or count <= 0:
|
||
return (
|
||
"参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;"
|
||
"不编造用户与参考图均未提供的信息。"
|
||
)
|
||
return (
|
||
"参考图规则:第1张为主商品图,请保持其主体、款式、颜色和关键细节准确;"
|
||
"第2至%d张仅作为风格、构图、场景或排版参考,不得替换主商品;"
|
||
"不编造用户与参考图均未提供的信息。" % (count + 1)
|
||
)
|
||
|
||
|
||
def product_suite_prompt_context(
|
||
base_prompt,
|
||
settings,
|
||
category,
|
||
item_id,
|
||
source_index=1,
|
||
reference_asset_count=0,
|
||
):
|
||
normalized = normalize_suite_settings(settings)
|
||
item_text = str(item_id or "").strip()
|
||
if not item_text or item_text.startswith("draft_"):
|
||
item_text = "未绑定商品"
|
||
reference_index = max(1, int(source_index or 1))
|
||
reference_rule = product_suite_reference_rule(
|
||
normalized,
|
||
reference_asset_count,
|
||
)
|
||
return {
|
||
"生成目标": category_description(category),
|
||
"套图名称": str(category or ""),
|
||
"补充描述": "," + category_description(category),
|
||
"平台": normalized["platform"],
|
||
"国家地区": normalized["country"],
|
||
"输出语言": normalized["language"],
|
||
"图片比例": normalized["ratio"],
|
||
"商品ID": item_text,
|
||
"主参考图序号": str(reference_index),
|
||
"参考图规则": reference_rule,
|
||
"商品卖点与要求": str(base_prompt or "").strip(),
|
||
"尺寸与长图规则": PRODUCT_SUITE_SIZE_RULE,
|
||
"禁用内容规则": PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE,
|
||
"价格信息规则": PRODUCT_SUITE_PRICE_RULE,
|
||
"尺码信息规则": PRODUCT_SUITE_SIZE_INFO_RULE,
|
||
}
|
||
|
||
|
||
def render_product_suite_prompt(template_text, context):
|
||
validate_product_suite_prompt(template_text)
|
||
values = {
|
||
name: str((context or {}).get(name, ""))
|
||
for name in PRODUCT_SUITE_PLACEHOLDERS
|
||
}
|
||
context = context or {}
|
||
missing_context = [
|
||
name
|
||
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
|
||
if name not in context
|
||
]
|
||
has_new_target = "生成目标" in context
|
||
has_legacy_target = all(
|
||
name in context for name in ("套图名称", "补充描述")
|
||
)
|
||
if not has_new_target and not has_legacy_target:
|
||
missing_context.append("生成目标")
|
||
if missing_context:
|
||
raise ProductSuitePromptError(
|
||
"提示词上下文缺少变量:%s"
|
||
% "、".join("{%s}" % name for name in missing_context)
|
||
)
|
||
rendered = _PRODUCT_SUITE_PLACEHOLDER_RE.sub(
|
||
lambda match: values[match.group(1)],
|
||
str(template_text),
|
||
)
|
||
if "{" in rendered or "}" in rendered:
|
||
raise ProductSuitePromptError("提示词渲染后仍有未替换变量")
|
||
return rendered.strip()
|
||
|
||
|
||
def build_suite_prompt(
|
||
base_prompt,
|
||
settings,
|
||
category,
|
||
item_id,
|
||
source_index=1,
|
||
reference_asset_count=0,
|
||
*,
|
||
template_text,
|
||
):
|
||
context = product_suite_prompt_context(
|
||
base_prompt,
|
||
settings,
|
||
category,
|
||
item_id,
|
||
source_index=source_index,
|
||
reference_asset_count=reference_asset_count,
|
||
)
|
||
return render_product_suite_prompt(template_text, context)
|
||
|
||
|
||
def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_text):
|
||
assets = list(source_assets or [])
|
||
if not assets:
|
||
return []
|
||
normalized = normalize_suite_settings(settings)
|
||
primary_asset_id = int(getattr(assets[0], "id", assets[0]))
|
||
reference_asset_ids = []
|
||
if not normalized["per_image_primary"]:
|
||
for asset in assets[1:8]:
|
||
asset_id = int(getattr(asset, "id", asset))
|
||
if asset_id != primary_asset_id and asset_id not in reference_asset_ids:
|
||
reference_asset_ids.append(asset_id)
|
||
specs = []
|
||
for category in category_order(normalized):
|
||
count = _count(normalized["categories"].get(category, 0))
|
||
if count <= 0:
|
||
continue
|
||
targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1]
|
||
for source_index, asset in enumerate(targets, 1):
|
||
for category_index in range(1, count + 1):
|
||
specs.append(
|
||
{
|
||
"source_asset_id": int(getattr(asset, "id", asset)),
|
||
"reference_asset_ids": list(reference_asset_ids),
|
||
"job_type": str(category),
|
||
"category": str(category),
|
||
"category_index": category_index,
|
||
"source_index": source_index,
|
||
"prompt": build_suite_prompt(
|
||
base_prompt,
|
||
normalized,
|
||
category,
|
||
item_id,
|
||
source_index=source_index,
|
||
reference_asset_count=len(reference_asset_ids),
|
||
template_text=template_text,
|
||
),
|
||
}
|
||
)
|
||
return specs
|
||
|
||
|
||
def _count(value):
|
||
try:
|
||
return max(0, int(value or 0))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def _choice(value, choices, default):
|
||
text = str(value or "").strip()
|
||
return text if text in choices else default
|