feat(video): 期3 视频趴 — 人物→角色 + 引用模特库/自动入库 + 分镜合规归类 + 资产库素材
后端: - B 路:视频流程新生成角色立绘+三视图成套 → 自动入模特库(run_triview_task,幂等 additive) - 合规闭合:三视图 person→tri_view、分镜图 scene→storyboard,两者均加 on_commit 送审(此前只有角色立绘送审,三视图/分镜图漏审 = 合规缺口) - 资产库 _tab_q:成片 finals→素材 materials(所有视频排除最终成片;最终成片隐藏不列) - 单测 TriviewAutoEnrollTests:自动入库 + 幂等 + 三视图落审核范围 前端: - 视频项目资产趴「人物」→「角色」(KIND_LABEL,区块名/新增/空态/详情弹窗一处全改;向导人设 persona 不动) - A 路:演员库收编模特库 —— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),选一个即用其形象图作角色参考 - 资产库 library:人物→角色、成片 tab→素材、分类标签补全(模特上身图/平台套图/自由创作/视频素材/最终成片) - vite.config:端口与后端代理目标可用 VITE_PORT/VITE_API_TARGET 覆盖(默认不变),避免多实例撞车 验收:后端单测全绿(基线既有 7 失败零新增);tsc+build 全绿;无头 0 console error (资产库角色/素材改名、最终成片隐藏;pipeline 资产趴显角色;演员库模特库 tab 含 5 模特) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c8f3f91d38
commit
c6e9c7e77b
@@ -851,7 +851,7 @@ def run_triview_task(*, task_id: str) -> None:
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
asset = _store_generated_media(
|
||||
team=project.team, user=user, project=project, task=task, media=media,
|
||||
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
|
||||
name=f"{project.name}-三视图", category=Asset.Category.TRI_VIEW, asset_type=Asset.Type.IMAGE,
|
||||
)
|
||||
# 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新
|
||||
group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at")
|
||||
@@ -864,6 +864,23 @@ def run_triview_task(*, task_id: str) -> None:
|
||||
group.candidate_assets.add(asset)
|
||||
group.adopted_asset = asset
|
||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||
# B 路(期3):视频流程新生成的「角色」立绘 + 三视图成套 → 自动加入模特库(团队级可复用)。
|
||||
# 幂等:同一立绘只建一次 Model;best-effort,不影响主链路。
|
||||
from apps.assets.models import Model as ModelEntity
|
||||
|
||||
portrait = Asset.objects.filter(id=asset_key).first()
|
||||
if portrait is not None and not ModelEntity.objects.filter(portrait_asset=portrait).exists():
|
||||
ModelEntity.objects.create(
|
||||
team=project.team, created_by=user,
|
||||
name=(portrait.name or f"{project.name}-角色").split("·")[0].strip()[:255] or "角色",
|
||||
source=ModelEntity.Source.AI,
|
||||
portrait_asset=portrait, triview_asset=asset,
|
||||
metadata={"from_project": str(project.id), "auto_enrolled": True},
|
||||
)
|
||||
# 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
|
||||
from apps.assets.review import submit_asset_for_review
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
except Exception as exc: # noqa: BLE001 — 失败退费 + 错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
@@ -1144,7 +1161,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
task=task,
|
||||
media=media,
|
||||
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
|
||||
category=Asset.Category.SCENE,
|
||||
category=Asset.Category.STORYBOARD,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
)
|
||||
with transaction.atomic():
|
||||
@@ -1163,6 +1180,10 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
sort_order=segment.sort_order,
|
||||
prompt=segment.visual_prompt,
|
||||
)
|
||||
# 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
|
||||
from apps.assets.review import submit_asset_for_review
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
|
||||
@@ -275,6 +275,64 @@ class StandaloneCategoryTests(TestCase):
|
||||
self.assertEqual(a.category, Asset.Category.PERSON)
|
||||
|
||||
|
||||
class TriviewAutoEnrollTests(TestCase):
|
||||
"""B 路(期3):视频流程新生成的角色立绘 + 三视图成套 → 自动入模特库;幂等不重复建。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.projects.models import Project
|
||||
|
||||
self.user = User.objects.create_user(username="trio", password="p")
|
||||
self.team = Team.objects.create(name="TR", 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="P")
|
||||
self.project = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
|
||||
self.portrait = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="项目甲-person", asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||
)
|
||||
AssetFile.objects.create(asset=self.portrait, object_key="p.png", bucket="b", content_type="image/png", preview_url="http://x/p.png", is_primary=True)
|
||||
|
||||
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/tri.png"}]}
|
||||
prov.extract_first_media_url.return_value = "http://x/tri.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)
|
||||
|
||||
def _gen_triview(self):
|
||||
from apps.ai.services import generate_person_triview, run_triview_task
|
||||
|
||||
task = generate_person_triview(project=self.project, user=self.user, portrait_asset=self.portrait)
|
||||
run_triview_task(task_id=str(task.id))
|
||||
|
||||
def test_triview_auto_enrolls_model(self):
|
||||
from apps.assets.models import Model
|
||||
|
||||
self._patch_provider()
|
||||
self._gen_triview()
|
||||
m = Model.objects.filter(portrait_asset=self.portrait).first()
|
||||
self.assertIsNotNone(m)
|
||||
self.assertIsNotNone(m.triview_asset) # 成套
|
||||
self.assertTrue(m.metadata.get("auto_enrolled"))
|
||||
self.assertEqual(m.source, Model.Source.AI)
|
||||
# 合规(期3):三视图归 tri_view → 落在送审范围内
|
||||
self.assertEqual(m.triview_asset.category, Asset.Category.TRI_VIEW)
|
||||
self.assertIn(m.triview_asset.category, Asset.REVIEW_CATEGORIES)
|
||||
|
||||
def test_auto_enroll_is_idempotent(self):
|
||||
from apps.assets.models import Model
|
||||
|
||||
self._patch_provider()
|
||||
self._gen_triview()
|
||||
self._gen_triview() # 同一立绘再出一版三视图
|
||||
self.assertEqual(Model.objects.filter(portrait_asset=self.portrait).count(), 1)
|
||||
|
||||
|
||||
class _FakeStreamResp:
|
||||
"""模拟 requests 流式响应:支持 with、raise_for_status、可写 encoding、iter_lines。"""
|
||||
status_code = 200
|
||||
|
||||
@@ -52,9 +52,9 @@ def _tab_q(tab: str) -> Q:
|
||||
return Q(category="free_create")
|
||||
if tab == "uploads":
|
||||
return Q(category="upload")
|
||||
if tab == "finals": # final_video,或「未归类但是视频」
|
||||
return Q(category="final_video") | (~Q(category__in=_KNOWN_CATS) & Q(asset_type="video"))
|
||||
if tab == "unclassified": # 未归类且非视频
|
||||
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
||||
return ~Q(category="final_video") & Q(asset_type="video")
|
||||
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
|
||||
return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")
|
||||
return Q()
|
||||
|
||||
@@ -110,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", "tryon", "kits", "creations", "finals", "uploads", "unclassified"]
|
||||
tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"]
|
||||
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
|
||||
@@ -2,15 +2,29 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { CSSProperties } from "react";
|
||||
import { api } from "../api";
|
||||
import type { Asset } from "../types";
|
||||
import type { Asset, ModelEntity } from "../types";
|
||||
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||
|
||||
// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。
|
||||
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)。
|
||||
// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),
|
||||
// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产。
|
||||
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从库里选一个回填到某基础资产卡)。
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||
// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」
|
||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated";
|
||||
// 模特库实体 → Asset 形(id=形象图资产 id,选中即作角色参考图);标 kind=model 归「模特库」tab
|
||||
const modelToAsset = (m: ModelEntity): Asset => ({
|
||||
id: m.portrait_asset as string,
|
||||
name: m.name,
|
||||
asset_type: "image",
|
||||
source: "ai_generated",
|
||||
category: "person",
|
||||
description: "",
|
||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
|
||||
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
|
||||
});
|
||||
// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」
|
||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model";
|
||||
|
||||
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
|
||||
open: boolean;
|
||||
@@ -57,8 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
// 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。
|
||||
const [fetched, setFetched] = useState<Asset[]>([]);
|
||||
const reload = useCallback(async () => {
|
||||
const res = await api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null);
|
||||
setFetched(res?.results ?? []);
|
||||
// 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出,
|
||||
// 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。
|
||||
const [persons, models] = await Promise.all([
|
||||
api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null),
|
||||
api.listModels({ pageSize: 200 }).catch(() => null)
|
||||
]);
|
||||
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
||||
setFetched([...(persons?.results ?? []), ...mapped]);
|
||||
}, []);
|
||||
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
||||
|
||||
@@ -228,7 +248,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
<div className="actorlib-body">
|
||||
<div className="actorlib-toolbar">
|
||||
<div className="actorlib-tabs">
|
||||
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}>平台预设演员 · {people.filter(isPreset).length}</button>
|
||||
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}>模特库 · {people.filter(isPreset).length}</button>
|
||||
<button className={`al-tab${tab === "mine" ? " active" : ""}`} type="button" onClick={() => setTab("mine")}>我的演员 · {people.filter((a) => !isPreset(a)).length}</button>
|
||||
</div>
|
||||
<span className="spacer" style={{ flex: 1 }}></span>
|
||||
@@ -266,7 +286,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
</>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||
<span className="ph-frame">// {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const LIB_PAGE_SIZE = 10;
|
||||
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
|
||||
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
|
||||
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
|
||||
const CATEGORY_LABELS: Record<string, string> = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" };
|
||||
const CATEGORY_LABELS: Record<string, string> = { person: "角色", scene: "场景", product_image: "商品图", model_tryon: "模特上身图", platform_kit: "平台套图", free_create: "自由创作", video_clip: "视频素材", final_video: "最终成片", upload: "上传", uncategorized: "未分类" };
|
||||
|
||||
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
|
||||
function inferAssetType(file: File): string {
|
||||
@@ -35,12 +35,13 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
|
||||
];
|
||||
|
||||
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
|
||||
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified";
|
||||
// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列)
|
||||
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified";
|
||||
|
||||
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
||||
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
|
||||
{ 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: "未分类" }
|
||||
{ key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
|
||||
];
|
||||
|
||||
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
|
||||
@@ -50,8 +51,8 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
||||
{ key: "role", label: "角色标签", tabs: ["people"] },
|
||||
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
|
||||
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
|
||||
{ key: "project", label: "关联项目", tabs: ["finals"] },
|
||||
{ key: "duration", label: "时长", tabs: ["finals"] },
|
||||
{ key: "project", label: "关联项目", tabs: ["materials"] },
|
||||
{ key: "duration", label: "时长", tabs: ["materials"] },
|
||||
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
|
||||
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
|
||||
];
|
||||
@@ -427,7 +428,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, tryon: 0, kits: 0, creations: 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, materials: 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);
|
||||
@@ -522,7 +523,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
|
||||
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 角色 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-materials">{counts.materials}</span> 素材</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
|
||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
|
||||
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
|
||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
|
||||
// 脚本来源 → 「来源」brief pill 文案
|
||||
const SOURCE_LABEL: Record<string, string> = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" };
|
||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||
|
||||
@@ -4,11 +4,12 @@ import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// 端口与后端代理目标可用环境变量覆盖,避免多项目/多实例撞车(默认保持原值)
|
||||
port: Number(process.env.VITE_PORT) || 5173,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8010",
|
||||
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8010",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 期3 走查:资产库「角色/素材」改名 + 最终成片隐藏 + pipeline 资产趴「角色」+ 演员库「模特库」tab。
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://localhost:5200";
|
||||
const PROJECT = process.env.PROJECT || "bdbf39d3-1a89-4135-b1b7-26d3331a7021";
|
||||
const OUT = path.resolve("../../../_qa_shots/models-p3");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const r = { consoleErrors: [], library: {}, pipeline: {} };
|
||||
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 });
|
||||
|
||||
// ── 资产库:角色 / 素材 改名,人物 / 成片 消失 ──
|
||||
await p.goto(BASE + "/library", { waitUntil: "load" });
|
||||
await p.waitForTimeout(2600);
|
||||
const libTabs = await p.locator(".tabs .tab, .tab").allInnerTexts().catch(() => []);
|
||||
const tabText = libTabs.join(" | ");
|
||||
r.library.tabs = tabText;
|
||||
r.library.hasRoleTab = /角色/.test(tabText);
|
||||
r.library.hasMaterialTab = /素材/.test(tabText);
|
||||
r.library.peopleTabGone = !/(^|\|)\s*人物\s*\d/.test(tabText) && !/人物\s/.test(tabText);
|
||||
r.library.finalsTabGone = !/成片/.test(tabText);
|
||||
await p.screenshot({ path: path.join(OUT, "library-tabs.png"), fullPage: true });
|
||||
|
||||
// ── pipeline 资产趴(stage 2):KIND_LABEL person → 角色 ──
|
||||
await p.goto(BASE + "/pipeline/" + PROJECT, { waitUntil: "load" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator('a[data-stage="2"]').first().click().catch(() => {});
|
||||
await p.waitForTimeout(2500);
|
||||
const bodyText = await p.locator('[data-stage-pane="2"]').innerText().catch(() => "");
|
||||
r.pipeline.hasRole = /角色/.test(bodyText);
|
||||
r.pipeline.hasPersonOld = /(新增人物|暂无人物)/.test(bodyText);
|
||||
await p.screenshot({ path: path.join(OUT, "pipeline.png"), fullPage: true });
|
||||
|
||||
// 尝试打开演员库(点资产趴里的「新增角色」或「模特库」入口),验证「模特库」tab
|
||||
let openedActorLib = false;
|
||||
const trigger = p.locator("button", { hasText: /新增角色|模特库|演员库/ }).first();
|
||||
if (await trigger.count()) {
|
||||
await trigger.click().catch(() => {});
|
||||
await p.waitForTimeout(1800);
|
||||
const alText = await p.locator(".actorlib").innerText().catch(() => "");
|
||||
r.pipeline.actorLibText = alText.slice(0, 120);
|
||||
r.pipeline.actorLibHasModelLib = /模特库/.test(alText);
|
||||
openedActorLib = await p.locator(".actorlib").count() > 0;
|
||||
if (openedActorLib) await p.screenshot({ path: path.join(OUT, "actor-library.png"), fullPage: true });
|
||||
}
|
||||
r.pipeline.openedActorLib = openedActorLib;
|
||||
|
||||
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