"""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) if normalized["per_image_primary"]: return ( "参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;" "不编造用户与参考图均未提供的信息。" ) return "参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。" 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