优化视频复刻和商品内容大小
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""上传图片的尺寸闸。
|
||||
|
||||
火山素材审核要求图片宽高在 300–6000 px 之间,小图会被
|
||||
[InvalidParameter.WidthTooSmall] 拒掉。这个限制以前只在自由创作的素材上传口做,
|
||||
商品图 / 项目上传等入口没有 —— 小图能一路存进商品库,直到生成视频送审那一刻才炸,
|
||||
而且报错发生在离上传很远的地方,用户根本对不上号。统一在入库前挡住。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
MIN_EDGE = 300
|
||||
MAX_EDGE = 6000
|
||||
|
||||
|
||||
class ImageTooSmallError(ValueError):
|
||||
"""尺寸不合格。文案直接面向用户。"""
|
||||
|
||||
|
||||
def probe_image_size(raw: bytes) -> tuple[int, int]:
|
||||
"""读图片宽高。读不出来抛 ImageTooSmallError(当成坏文件)。"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
return int(im.width), int(im.height)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ImageTooSmallError("图片解析失败,请更换文件") from exc
|
||||
|
||||
|
||||
def ensure_reviewable_image(raw: bytes, *, label: str = "图片") -> tuple[int, int]:
|
||||
"""校验上传图片的宽高,返回 (width, height)。不合格抛 ImageTooSmallError。"""
|
||||
width, height = probe_image_size(raw)
|
||||
if not (MIN_EDGE <= width <= MAX_EDGE and MIN_EDGE <= height <= MAX_EDGE):
|
||||
raise ImageTooSmallError(
|
||||
f"{label}尺寸需在 {MIN_EDGE}–{MAX_EDGE} 像素之间(当前 {width}×{height}),"
|
||||
"过小的图无法通过素材审核,请换一张更大的图"
|
||||
)
|
||||
return width, height
|
||||
@@ -95,6 +95,7 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
|
||||
if not remote_id:
|
||||
# 火山没回 Id:不要标 processing(否则 remote_id 为空、poll 永远早退、卡死黄),留空可重试
|
||||
logger.warning("create_asset 返回空 id,asset %s 暂不送审(可重试)", asset.id)
|
||||
_record_submit_error(asset, "审核服务未返回素材编号")
|
||||
return False
|
||||
asset.review_remote_id = remote_id
|
||||
asset.review_status = "processing"
|
||||
@@ -103,9 +104,42 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("submit_asset_for_review failed for asset %s: %s", asset.id, exc)
|
||||
# 只写日志等于把原因埋了:调用方(视频复刻等)只能报「素材提交审核失败」这种没信息量的话,
|
||||
# 用户和排查都无从下手。原因落到 review_error,让上层能带出来。
|
||||
_record_submit_error(asset, str(exc))
|
||||
return False
|
||||
|
||||
|
||||
# 火山素材审核的常见拒因:原文是英文错误码,直接怼给用户看不懂也不知道怎么办。
|
||||
_SUBMIT_ERROR_HINTS = (
|
||||
("WidthTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"),
|
||||
("HeightTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"),
|
||||
("WidthTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"),
|
||||
("HeightTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"),
|
||||
("SizeTooLarge", "图片文件太大,请压缩后重试"),
|
||||
("InvalidImageFormat", "图片格式不支持,请改用 JPG / PNG / WebP"),
|
||||
("DownloadFailed", "审核服务拉取不到这张图,请重新上传"),
|
||||
)
|
||||
|
||||
|
||||
def humanize_submit_error(reason: str) -> str:
|
||||
"""把火山的英文错误码换成能照着做的中文。认不出的原样返回。"""
|
||||
text = reason or ""
|
||||
for code, hint in _SUBMIT_ERROR_HINTS:
|
||||
if code in text:
|
||||
return hint
|
||||
return text
|
||||
|
||||
|
||||
def _record_submit_error(asset: Asset, reason: str) -> None:
|
||||
"""把送审失败原因写回资产。写失败不抛 —— 审计不能反过来搞挂主流程。"""
|
||||
try:
|
||||
asset.review_error = humanize_submit_error(reason)[:2000]
|
||||
asset.save(update_fields=["review_error", "updated_at"])
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("记录 asset %s 送审失败原因时出错", asset.id, exc_info=True)
|
||||
|
||||
|
||||
# 平台自己生成的资产,提示词与生成链路都在我们手里,视为免审;用户上传的必须真过一遍审核。
|
||||
# 判据是 Asset.source 而不是「在不在某个库里」—— 按库免审等于把审核架空:
|
||||
# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。
|
||||
|
||||
@@ -819,6 +819,20 @@ class FacetsProductsTests(TestCase):
|
||||
data = self.client.get("/api/assets/facets/?tab=others").json()
|
||||
self.assertEqual(data["products"], [])
|
||||
|
||||
def test_facets_products_newest_first(self):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from apps.products.models import Product
|
||||
|
||||
Asset.objects.create(
|
||||
team=self.team, name="上身图2", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p2.id)},
|
||||
)
|
||||
Product.objects.filter(id=self.p1.id).update(created_at=timezone.now() - timedelta(days=1))
|
||||
data = self.client.get("/api/assets/facets/?tab=tryon").json()
|
||||
self.assertEqual([p["id"] for p in data["products"]], [str(self.p2.id), str(self.p1.id)])
|
||||
|
||||
|
||||
class SubmitReviewTests(TestCase):
|
||||
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
|
||||
@@ -927,3 +941,55 @@ class ReviewScopeTests(TestCase):
|
||||
self.assertEqual(out["statuses"][str(proc.id)], "active")
|
||||
sub.assert_called_once()
|
||||
polled.assert_called_once()
|
||||
|
||||
|
||||
class ImageSizeGuardTests(TestCase):
|
||||
"""火山素材审核要求图片 300–6000 px。小图以前能一路存进商品库,
|
||||
直到生成视频送审那一刻才炸([InvalidParameter.WidthTooSmall]) —— 报错离上传很远,
|
||||
用户对不上号。改成入库前就挡住。"""
|
||||
|
||||
def _png(self, width, height):
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (width, height), "white").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def test_rejects_image_below_300px(self):
|
||||
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
|
||||
|
||||
with self.assertRaises(ImageTooSmallError) as ctx:
|
||||
ensure_reviewable_image(self._png(120, 800), label="商品图")
|
||||
self.assertIn("商品图", str(ctx.exception))
|
||||
self.assertIn("300", str(ctx.exception))
|
||||
self.assertIn("120×800", str(ctx.exception)) # 把实际尺寸写给用户,免得反复试
|
||||
|
||||
def test_rejects_image_above_6000px(self):
|
||||
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
|
||||
|
||||
with self.assertRaises(ImageTooSmallError):
|
||||
ensure_reviewable_image(self._png(6400, 900))
|
||||
|
||||
def test_accepts_image_in_range_and_returns_size(self):
|
||||
from apps.assets.image_guard import ensure_reviewable_image
|
||||
|
||||
self.assertEqual(ensure_reviewable_image(self._png(800, 1200)), (800, 1200))
|
||||
|
||||
def test_broken_file_is_rejected(self):
|
||||
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
|
||||
|
||||
with self.assertRaises(ImageTooSmallError):
|
||||
ensure_reviewable_image(b"not-an-image")
|
||||
|
||||
def test_volcano_error_codes_are_humanized(self):
|
||||
from apps.assets.review import humanize_submit_error
|
||||
|
||||
self.assertIn(
|
||||
"图片太小",
|
||||
humanize_submit_error("[InvalidParameter.WidthTooSmall] Width must be between 300px and 6000px."),
|
||||
)
|
||||
self.assertIn("格式不支持", humanize_submit_error("[InvalidParameter.InvalidImageFormat] bad"))
|
||||
# 认不出的原样带出,不要吞掉
|
||||
self.assertEqual(humanize_submit_error("Some brand new error"), "Some brand new error")
|
||||
|
||||
@@ -413,11 +413,13 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
product_ids.update(str(x) for x in pid3 if x)
|
||||
if not product_ids:
|
||||
return []
|
||||
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title
|
||||
rows = Product.objects.filter(team=self.get_team(), id__in=product_ids).values("id", "title")
|
||||
products = [{"id": str(r["id"]), "title": r["title"]} for r in rows]
|
||||
products.sort(key=lambda x: x["title"] or "")
|
||||
return products
|
||||
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title,最新添加的在前
|
||||
rows = (
|
||||
Product.objects.filter(team=self.get_team(), id__in=product_ids)
|
||||
.order_by("-created_at")
|
||||
.values("id", "title")
|
||||
)
|
||||
return [{"id": str(r["id"]), "title": r["title"]} for r in rows]
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="raw")
|
||||
def raw(self, request, pk=None):
|
||||
|
||||
Reference in New Issue
Block a user