优化视频复刻和商品内容大小

This commit is contained in:
Azmat@qq.com
2026-08-28 18:42:28 +08:00
parent a618f02315
commit 6b6146e595
20 changed files with 1557 additions and 382 deletions
+39
View File
@@ -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
+34
View File
@@ -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 而不是「在不在某个库里」—— 按库免审等于把审核架空:
# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。
+66
View File
@@ -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")
+7 -5
View File
@@ -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):