diff --git a/.memory/worklog.json b/.memory/worklog.json index 368dacb..f8bc027 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,18 +1,5 @@ { "entries": [ - { - "files_changed": 2, - "message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-15 11:40 (~1)", - "ts": "2026-05-15T03:44:44Z", - "type": "session-heartbeat" - }, - { - "files_changed": 2, - "hash": "23b1def", - "message": "auto-save 2026-05-15 11:45 (~2)", - "ts": "2026-05-15T11:46:03+08:00", - "type": "commit" - }, { "files_changed": 3, "hash": "de3cef4", @@ -3264,6 +3251,19 @@ "message": "auto-save 2026-05-17 19:32 (~4)", "hash": "96c998c", "files_changed": 4 + }, + { + "ts": "2026-05-17T19:37:40+08:00", + "type": "commit", + "message": "auto-save 2026-05-17 19:37 (~4)", + "hash": "5c6a16d", + "files_changed": 4 + }, + { + "ts": "2026-05-17T11:38:28Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 19:37 (~4)", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index 2e8aea2..4c03ea6 100644 --- a/api/main.py +++ b/api/main.py @@ -51,6 +51,10 @@ TRANSLATE_MODEL = os.getenv("TRANSLATE_MODEL", "gemini-2.5-flash") REWRITE_MODEL = os.getenv("REWRITE_MODEL", "gemini-2.5-pro") VISION_MODEL = os.getenv("VISION_MODEL", "gemini-2.5-flash") IMAGE_MODEL = os.getenv("IMAGE_MODEL", "gemini-3-pro-image-preview") +PRODUCT_ASSET_MAX_SIDE = max(1024, int(os.getenv("PRODUCT_ASSET_MAX_SIDE", "1600"))) +PRODUCT_ASSET_MIN_LONG_SIDE = max(512, int(os.getenv("PRODUCT_ASSET_MIN_LONG_SIDE", "900"))) +PRODUCT_ASSET_MIN_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600"))) +PRODUCT_ASSET_JPEG_QUALITY = max(80, min(95, int(os.getenv("PRODUCT_ASSET_JPEG_QUALITY", "92")))) VIDEO_MODEL = os.getenv("VIDEO_MODEL", "seedance").strip() or "seedance" AUDIO_PRODUCT_BRIEF = os.getenv( "AUDIO_PRODUCT_BRIEF", @@ -4234,6 +4238,64 @@ def get_skg_character_library_image(filename: str): return FileResponse(p, media_type=media_type) +def normalize_product_asset_image(src: Path, out: Path) -> dict: + original_bytes = src.stat().st_size if src.exists() else 0 + actions: list[str] = [] + warnings: list[str] = [] + with Image.open(src) as opened: + img = ImageOps.exif_transpose(opened) + original_width, original_height = img.size + if img.mode in {"RGBA", "LA"} or ("transparency" in img.info): + rgba = img.convert("RGBA") + base = Image.new("RGB", img.size, (255, 255, 255)) + base.paste(rgba, mask=rgba.getchannel("A")) + img = base + actions.append("透明背景已铺白") + elif img.mode != "RGB": + img = img.convert("RGB") + actions.append("已转 RGB/JPEG") + + max_side = max(img.size) + if max_side > PRODUCT_ASSET_MAX_SIDE: + ratio = PRODUCT_ASSET_MAX_SIDE / max_side + next_size = (max(1, round(img.width * ratio)), max(1, round(img.height * ratio))) + img = img.resize(next_size, Image.Resampling.LANCZOS) + actions.append(f"最长边压缩到 {PRODUCT_ASSET_MAX_SIDE}px") + if max(original_width, original_height) >= 2400: + warnings.append("原图过大已自动压缩;超高清不会提升识别稳定性") + elif max_side < PRODUCT_ASSET_MIN_LONG_SIDE: + ratio = PRODUCT_ASSET_MIN_LONG_SIDE / max_side + next_size = (max(1, round(img.width * ratio)), max(1, round(img.height * ratio))) + img = img.resize(next_size, Image.Resampling.LANCZOS) + actions.append(f"低分辨率图已放大到最长边 {PRODUCT_ASSET_MIN_LONG_SIDE}px") + warnings.append("原始分辨率偏低,已放大为工作图,但真实细节不会增加") + + if min(img.size) < PRODUCT_ASSET_MIN_SHORT_SIDE: + warnings.append(f"短边低于 {PRODUCT_ASSET_MIN_SHORT_SIDE}px,细节/比例识别可能不稳") + if original_bytes >= 5 * 1024 * 1024: + warnings.append("原文件较大,已生成轻量 AI 工作副本") + + out.parent.mkdir(parents=True, exist_ok=True) + img.save(out, "JPEG", quality=PRODUCT_ASSET_JPEG_QUALITY, optimize=True, progressive=True, subsampling=0) + + return { + "standard": f"AI工作副本:最长边≤{PRODUCT_ASSET_MAX_SIDE}px,建议长边≥{PRODUCT_ASSET_MIN_LONG_SIDE}px,短边≥{PRODUCT_ASSET_MIN_SHORT_SIDE}px,JPEG q{PRODUCT_ASSET_JPEG_QUALITY}", + "original_width": original_width, + "original_height": original_height, + "width": img.width, + "height": img.height, + "original_bytes": original_bytes, + "work_bytes": out.stat().st_size if out.exists() else 0, + "max_side": PRODUCT_ASSET_MAX_SIDE, + "min_long_side": PRODUCT_ASSET_MIN_LONG_SIDE, + "min_short_side": PRODUCT_ASSET_MIN_SHORT_SIDE, + "quality": PRODUCT_ASSET_JPEG_QUALITY, + "actions": actions, + "warnings": warnings, + "normalized": bool(actions or warnings), + } + + @app.post("/jobs/{job_id}/assets") async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict: if job_id not in JOBS: @@ -4245,9 +4307,7 @@ async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> out = out_dir / f"{asset_id}.jpg" try: tmp.write_bytes(await file.read()) - img = Image.open(tmp).convert("RGB") - img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) - img.save(out, "JPEG", quality=94) + asset_meta = normalize_product_asset_image(tmp, out) except Exception as e: raise HTTPException(400, f"product image upload failed: {e}") finally: @@ -4261,6 +4321,7 @@ async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> "element_id": asset_id, "cutout_id": asset_id, "label": file.filename or "SKG 产品图", + "asset_meta": asset_meta, } @@ -4482,9 +4543,7 @@ def copy_product_library_asset(job_id: str, req: CopyProductLibraryAssetReq) -> out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / f"{asset_id}.jpg" try: - img = Image.open(src).convert("RGB") - img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) - img.save(out, "JPEG", quality=94) + asset_meta = normalize_product_asset_image(src, out) except Exception as e: raise HTTPException(400, f"product library copy failed: {e}") label = f"产品融合 · {item.title} #{item.image_index}" @@ -4494,6 +4553,7 @@ def copy_product_library_asset(job_id: str, req: CopyProductLibraryAssetReq) -> "element_id": asset_id, "cutout_id": asset_id, "label": label, + "asset_meta": asset_meta, } diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 5f0c8f7..ac5eaee 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -94,6 +94,7 @@ type ProductRefItem = { note: string risk: string source: "upload" | "ai" + assetMeta?: ImageRef["asset_meta"] confidence?: number } @@ -356,6 +357,11 @@ function productBackgroundLabel(background: string) { return PRODUCT_BACKGROUND_LABELS[background] ?? PRODUCT_BACKGROUND_LABELS.unknown } +function formatProductAssetSize(meta?: ImageRef["asset_meta"]) { + if (!meta?.width || !meta?.height) return "AI工作图" + return `${meta.width}x${meta.height}` +} + function defaultProductUseTags(view: string) { const defaults: Record = { front: ["hero_packshot", "asymmetry"], @@ -398,6 +404,7 @@ function createProductRefItem( note: note ?? targetSlot.hint, risk, source, + assetMeta: ref.asset_meta, confidence, } } @@ -1343,7 +1350,7 @@ function AudioStoryboardPlanPanel({ )}

- 上传的图默认属于同一个产品;系统只标注背景、视角、用途和生成风险。每条视频生成时自动挑选最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图,避免把所有素材都塞给模型。 + 上传原图不限尺寸,但系统会自动生成轻量 AI 工作副本:最长边 1600px、JPEG 92。超高清原图不会更稳;低分辨率会自动放大并标注风险。每条视频只挑最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图。

@@ -1506,6 +1513,8 @@ function ProductReferenceCard({ }) { const src = resolveImageRefUrl(job.id, item.ref) const tagLabels = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean) + const assetWarnings = item.assetMeta?.warnings ?? [] + const assetActions = item.assetMeta?.actions ?? [] return (
@@ -1517,6 +1526,7 @@ function ProductReferenceCard({
{item.note} {item.risk ? <>
风险:{item.risk} : null} + {assetWarnings.length ? <>
规格:{assetWarnings.join(";")} : null}
{item.source === "ai" ? "AI" : "图"} @@ -1529,11 +1539,13 @@ function ProductReferenceCard({
+ {formatProductAssetSize(item.assetMeta)} {productBackgroundLabel(item.background)} {tagLabels.slice(0, 3).map((tag) => ( {tag} ))} - {item.risk ? 有风险 : null} + {item.risk || assetWarnings.length ? 需留意 : null} + {assetActions.length ? 已标准化 : null}