feat(images): 期2 图片趴三类 — 模特上身图/平台套图/自由创作 归类+成组+接模特库
后端: - 独立生图三类归类:model+product→model_tryon(模特上身图,引用模特库,不送审) / cover→platform_kit(平台套图) / image→free_create(自由创作);生成演员(model 无 product)仍→person(视频角色,留期3) - 成组:每次提交一个 batch_id 串起整批,落 asset.metadata;另记 mode + model_entity_id(上身图溯源模特库) - generate-image 端点透传 model_entity_id;资产库 _tab_q+summary 加 tryon/kits/creations 三类桶 - 单测 StandaloneCategoryTests 直跑 worker 验四态归类全绿(绕开异步 .delay) 前端: - 资产库 library.tsx 加三类 tab(模特上身图/平台套图/自由创作)+ 计数 + 筛选维度 - ai-tools 模特选择器数据源 person→模特库(listModels 映射,选中传形象图当参考 = 引用模特库),ActorLibrary 同源 - api.ts submitGenerateImage 加 model_entity_id 验收:tsc+build 全绿;无头 0 console error(资产库三类 tab 各归类正确、模特选择器 5 张取自模特库) 基线既有 3 失败(StandaloneImageReferenceTests 异步漂移)零新增 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
44e90233ff
commit
c8f3f91d38
@@ -1494,10 +1494,13 @@ def create_export_job(*, timeline, user) -> ExportJob:
|
||||
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||||
|
||||
|
||||
# 图片趴三类(模特库+资产模型重构 期2):
|
||||
# · model + product → model_tryon(模特上身图);model 无 product → person(视频角色「生成演员」,留期3)
|
||||
# · cover → platform_kit(平台套图);image → free_create(自由创作)
|
||||
_STANDALONE_CATEGORY = {
|
||||
"model": Asset.Category.PERSON,
|
||||
"cover": Asset.Category.PRODUCT_IMAGE,
|
||||
"image": Asset.Category.PRODUCT_IMAGE,
|
||||
"cover": Asset.Category.PLATFORM_KIT,
|
||||
"image": Asset.Category.FREE_CREATE,
|
||||
}
|
||||
_STANDALONE_TASK_TYPE = {
|
||||
"model": AITask.Type.PERSON_IMAGE,
|
||||
@@ -1535,7 +1538,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
||||
continue
|
||||
|
||||
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, ratio: str | None = None) -> list[AITask]:
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None) -> list[AITask]:
|
||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||
|
||||
@@ -1550,6 +1553,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
raise ValueError("no active image model configured")
|
||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||
count = max(1, min(int(count or 1), 12))
|
||||
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
|
||||
batch_id = str(uuid.uuid4())
|
||||
tasks: list[AITask] = []
|
||||
for index in range(count):
|
||||
cost = estimate_cost(model_config)
|
||||
@@ -1561,7 +1566,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "ratio": str(ratio) if ratio else None},
|
||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None},
|
||||
estimated_cost=cost,
|
||||
)
|
||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||||
@@ -1588,10 +1593,10 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
mode = str(payload.get("mode") or "image")
|
||||
index = int(payload.get("index") or 0)
|
||||
product_id = payload.get("product_id") or None
|
||||
# 模特上身图(mode=model 且绑了商品)= 该商品的商品图,归到对应商品的 AI 资产,不进人物库;
|
||||
# 「生成演员」同样走 mode=model 但无 product_id,仍归人物库(PERSON)。
|
||||
# 模特上身图(mode=model 且绑了商品)= 图片趴「模特上身图」(引用模特库),归 model_tryon、不送审;
|
||||
# 「生成演员」同样走 mode=model 但无 product_id = 视频角色,仍归 person(送审,留期3 收编为「角色」)。
|
||||
if mode == "model" and product_id:
|
||||
category = Asset.Category.PRODUCT_IMAGE
|
||||
category = Asset.Category.MODEL_TRYON
|
||||
else:
|
||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||
model_config = task.model_config
|
||||
@@ -1652,11 +1657,18 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
asset_label = {"model": "模特上身图", "cover": "平台套图", "image": "图片创作"}.get(mode, mode)
|
||||
# 资产元数据:product_id(商品详情页据此只展示该商品素材)+ batch_id(成组)+ mode + model_entity_id(上身图溯源模特库)
|
||||
asset_meta: dict = {"mode": mode}
|
||||
if product_id:
|
||||
asset_meta["product_id"] = str(product_id)
|
||||
if payload.get("batch_id"):
|
||||
asset_meta["batch_id"] = str(payload["batch_id"])
|
||||
if payload.get("model_entity_id"):
|
||||
asset_meta["model_entity_id"] = str(payload["model_entity_id"])
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
|
||||
metadata={"product_id": str(product_id)} if product_id else {},
|
||||
metadata=asset_meta,
|
||||
)
|
||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
|
||||
@@ -212,6 +212,69 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
prov.image_edit.assert_not_called()
|
||||
|
||||
|
||||
class StandaloneCategoryTests(TestCase):
|
||||
"""图片趴三类归类(期2):模特上身图→model_tryon / 平台套图→platform_kit / 自由创作→free_create;
|
||||
生成演员(model 无 product)仍→person(视频角色)。直接跑 worker 函数避开异步 .delay。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="catowner", password="pass")
|
||||
self.team = Team.objects.create(name="CT", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品")
|
||||
cover = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="主图", asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
|
||||
self.product.cover_asset = cover
|
||||
self.product.save(update_fields=["cover_asset"])
|
||||
|
||||
def _patch_provider(self):
|
||||
provider = patch("apps.ai.services.get_image_provider").start()
|
||||
prov = provider.return_value
|
||||
prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||
prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||
prov.extract_first_media_url.return_value = "http://x/out.png"
|
||||
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
|
||||
media.return_value = (BytesIO(b"img"), "image/png")
|
||||
store = patch("apps.ai.services.TosStorage").start()
|
||||
stored = store.return_value.upload_fileobj.return_value
|
||||
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
|
||||
self.addCleanup(patch.stopall)
|
||||
return prov
|
||||
|
||||
def _run(self, mode, *, product_id=None, model_id=None):
|
||||
from apps.ai.services import run_standalone_image_task
|
||||
|
||||
self._patch_provider()
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team, user=self.user, prompt="x", mode=mode, count=1,
|
||||
product_id=product_id, model_id=model_id, model_entity_id="ent-1", ratio="4:5",
|
||||
)
|
||||
run_standalone_image_task(task_id=str(tasks[0].id))
|
||||
return Asset.objects.filter(origin_task=tasks[0]).first()
|
||||
|
||||
def test_model_tryon_category(self):
|
||||
a = self._run("model", product_id=str(self.product.id))
|
||||
self.assertEqual(a.category, Asset.Category.MODEL_TRYON)
|
||||
self.assertEqual(a.metadata.get("mode"), "model")
|
||||
self.assertTrue(a.metadata.get("batch_id")) # 成组
|
||||
self.assertEqual(a.metadata.get("model_entity_id"), "ent-1") # 溯源模特库
|
||||
|
||||
def test_platform_kit_category(self):
|
||||
a = self._run("cover", product_id=str(self.product.id))
|
||||
self.assertEqual(a.category, Asset.Category.PLATFORM_KIT)
|
||||
|
||||
def test_free_create_category(self):
|
||||
a = self._run("image")
|
||||
self.assertEqual(a.category, Asset.Category.FREE_CREATE)
|
||||
|
||||
def test_generate_actor_stays_person(self):
|
||||
# 生成演员:mode=model 但无 product → 视频角色,仍 person(送审范围内)
|
||||
a = self._run("model")
|
||||
self.assertEqual(a.category, Asset.Category.PERSON)
|
||||
|
||||
|
||||
class _FakeStreamResp:
|
||||
"""模拟 requests 流式响应:支持 with、raise_for_status、可写 encoding、iter_lines。"""
|
||||
status_code = 200
|
||||
|
||||
@@ -32,10 +32,11 @@ class GenerateImageView(APIView):
|
||||
product_id = str(request.data.get("product_id") or "").strip() or None
|
||||
reference_product = bool(request.data.get("reference_product"))
|
||||
model_id = str(request.data.get("model_id") or "").strip() or None
|
||||
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
||||
ratio = str(request.data.get("ratio") or "").strip() or None
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, ratio=ratio)
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio)
|
||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
|
||||
@@ -30,7 +30,11 @@ class AssetPagination(PageNumberPagination):
|
||||
|
||||
|
||||
# 资产库 tab → 查询条件。与前端 assetTab(library.tsx)完全一致,供服务端按页过滤/计数。
|
||||
_KNOWN_CATS = ["person", "scene", "product_image", "final_video", "upload"]
|
||||
# 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作。
|
||||
_KNOWN_CATS = [
|
||||
"person", "scene", "product_image", "final_video", "upload",
|
||||
"model_tryon", "platform_kit", "free_create",
|
||||
]
|
||||
|
||||
|
||||
def _tab_q(tab: str) -> Q:
|
||||
@@ -40,6 +44,12 @@ def _tab_q(tab: str) -> Q:
|
||||
return Q(category="scene")
|
||||
if tab == "products":
|
||||
return Q(category="product_image")
|
||||
if tab == "tryon": # 模特上身图(图片趴)
|
||||
return Q(category="model_tryon")
|
||||
if tab == "kits": # 平台套图(图片趴)
|
||||
return Q(category="platform_kit")
|
||||
if tab == "creations": # 自由创作(图片趴)
|
||||
return Q(category="free_create")
|
||||
if tab == "uploads":
|
||||
return Q(category="upload")
|
||||
if tab == "finals": # final_video,或「未归类但是视频」
|
||||
@@ -100,7 +110,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def summary(self, request):
|
||||
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
|
||||
base = Asset.objects.filter(team=self.get_team())
|
||||
tabs = ["people", "scenes", "products", "finals", "uploads", "unclassified"]
|
||||
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "finals", "uploads", "unclassified"]
|
||||
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
|
||||
@@ -511,7 +511,7 @@ export const api = {
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||
},
|
||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) {
|
||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string }) {
|
||||
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
generateImageStatus(ids: string[]) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
WandSparkles,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types";
|
||||
import { api } from "../api";
|
||||
import { ActorLibrary } from "../components/actor-library";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
@@ -580,16 +580,34 @@ export function ImageWorkbenchPage({
|
||||
|
||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||
|
||||
/* 模特卡数据来源:服务端按 category=person 懒加载(不再吃全局 assets 全量数组);
|
||||
无则回退到基线占位模特卡 Ava/Luna/Mia/Zoe */
|
||||
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
||||
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
||||
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
|
||||
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.assetsPage({ category: "person", pageSize: 200 })
|
||||
.then((res) => { if (alive) setPersonAssets(res.results); })
|
||||
.catch(() => { if (alive) setPersonAssets([]); });
|
||||
return () => { alive = false; };
|
||||
const loadModels = useCallback(() => {
|
||||
api.listModels({ pageSize: 200 })
|
||||
.then((res) => {
|
||||
const mapped: Asset[] = res.results
|
||||
.filter((m: ModelEntity) => m.portrait_asset)
|
||||
.map((m: ModelEntity) => ({
|
||||
id: m.portrait_asset as string,
|
||||
name: m.name,
|
||||
asset_type: "image",
|
||||
source: m.source === "upload" ? "upload" : "ai_generated",
|
||||
category: "model_portrait",
|
||||
description: m.description || "",
|
||||
metadata: { model_entity_id: m.id },
|
||||
files: m.portrait
|
||||
? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }]
|
||||
: [],
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
}));
|
||||
setPersonAssets(mapped);
|
||||
})
|
||||
.catch(() => setPersonAssets([]));
|
||||
}, []);
|
||||
useEffect(() => { loadModels(); }, [loadModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) setPrompt(meta.promptTemplate(product.title));
|
||||
@@ -1524,7 +1542,7 @@ export function ImageWorkbenchPage({
|
||||
onPick={(assetId, assetName) => {
|
||||
setPickedIds([assetId]);
|
||||
setPickedModelName(assetName || "");
|
||||
void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {});
|
||||
loadModels();
|
||||
setActorLibOpen(false);
|
||||
}}
|
||||
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
|
||||
@@ -1537,7 +1555,7 @@ export function ImageWorkbenchPage({
|
||||
return api.uploadAsset(fd).catch(() => null);
|
||||
}}
|
||||
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
|
||||
onRefresh={() => { void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {}); }}
|
||||
onRefresh={() => { loadModels(); }}
|
||||
/>
|
||||
|
||||
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
|
||||
|
||||
@@ -34,10 +34,12 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
|
||||
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
|
||||
];
|
||||
|
||||
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
|
||||
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
|
||||
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified";
|
||||
|
||||
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
||||
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
|
||||
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
|
||||
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
|
||||
];
|
||||
|
||||
@@ -47,11 +49,11 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||
{ key: "age", label: "年龄段", tabs: ["people"] },
|
||||
{ key: "role", label: "角色标签", tabs: ["people"] },
|
||||
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
|
||||
{ key: "product", label: "关联商品", tabs: ["products"] },
|
||||
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
|
||||
{ key: "project", label: "关联项目", tabs: ["finals"] },
|
||||
{ key: "duration", label: "时长", tabs: ["finals"] },
|
||||
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
|
||||
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
|
||||
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
|
||||
];
|
||||
|
||||
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
|
||||
@@ -425,7 +427,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, finals: 0, uploads: 0, unclassified: 0 });
|
||||
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, finals: 0, uploads: 0, unclassified: 0 });
|
||||
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// 期2 图片趴三类走查:资产库三类 tab(归类) + 图片生成页模特选择器接模特库 + 抓 console/pageerror。
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://localhost:5188";
|
||||
const OUT = path.resolve("../../../_qa_shots/models-p2");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const r = { consoleErrors: [], library: {}, picker: {} };
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message));
|
||||
|
||||
// 登录
|
||||
await p.goto(BASE + "/login", { waitUntil: "load" });
|
||||
await p.waitForTimeout(400);
|
||||
await p.fill("#auth-username", "airshelf");
|
||||
await p.fill("#auth-pwd", "Restraint2026");
|
||||
await p.click("button.btn-cta");
|
||||
await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 });
|
||||
|
||||
// ── 资产库:三类 tab 存在 + 计数 + 逐 tab 截图 ──
|
||||
await p.goto(BASE + "/library", { waitUntil: "load" });
|
||||
await p.waitForTimeout(2600);
|
||||
for (const key of ["tryon", "kits", "creations"]) {
|
||||
r.library[key + "_tabExists"] = await p.locator(`.tab[data-tab="${key}"]`).count();
|
||||
}
|
||||
async function openTab(key) {
|
||||
await p.locator(`.tab[data-tab="${key}"]`).click();
|
||||
await p.waitForTimeout(2200);
|
||||
const cards = await p.locator(".asset-card, .lib-card, .ph-frame, .a-card").count();
|
||||
await p.screenshot({ path: path.join(OUT, `library-${key}.png`), fullPage: true });
|
||||
return cards;
|
||||
}
|
||||
r.library.tryonCards = await openTab("tryon");
|
||||
r.library.kitsCards = await openTab("kits");
|
||||
r.library.creationsCards = await openTab("creations");
|
||||
|
||||
// ── 图片生成页:模特上身图 → 模特选择器接模特库 ──
|
||||
await p.goto(BASE + "/model-photo", { waitUntil: "load" });
|
||||
await p.waitForTimeout(2600);
|
||||
r.picker.url = p.url();
|
||||
r.picker.modelCards = await p.locator(".model-grid .model-card").count();
|
||||
r.picker.firstModelName = await p.locator(".model-grid .model-card .m-name").first().innerText().catch(() => "");
|
||||
await p.screenshot({ path: path.join(OUT, "model-photo-picker.png"), fullPage: true });
|
||||
|
||||
await browser.close();
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));
|
||||
Reference in New Issue
Block a user