feat(pipeline): 基础资产页 A三视图手动 + B进故事板拦截 + C采用/未采用 + D删除
- A 三视图改手动:删"打开角色详情自动出三视图"逻辑、角色生成只出立绘(不再链式自动三视图); 详情弹窗保留「AI 生成三视图」按钮作手动入口(提示文案去掉"自动") - B 进下一步拦截:「确认资产,进入故事板」按钮挂 guardGen —— 被引用角色/场景缺"已采用"参考图就弹窗拦 - C 采用/未采用:角色/场景卡加 toggle 徽章(已采用绿/未采用灰);默认按"实体名是否在脚本里"派生, 用户可点击覆盖并持久化(metadata.adopt);后端 set-base-asset-adopt 端点;下游 _storyboard_reference_images 只取已采用组(商品组永远保留、顺带排除未采用/三视图组的兜底误抓) - D 删除:角色/场景卡 hover 显删除按钮(二次确认),商品主卡不给删;后端 delete-base-asset(连带三视图组) - 顺手:提取闸门按钮去掉"不自动花钱"文案 验收:projects/assets/ai 全套(基线既有 7 失败零新增,新增 set-adopt/delete 3 测过);tsc+build 绿; 无头核对(采用 toggle/hover 删除/待审核盾齐活、0 console error) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1024,6 +1024,22 @@ def _storyboard_reference_images(project, segment) -> list[dict]:
|
|||||||
"scene": BaseAssetGroup.Kind.SCENE,
|
"scene": BaseAssetGroup.Kind.SCENE,
|
||||||
}
|
}
|
||||||
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
||||||
|
# 下游只取「已采用」的角色/场景组(C/D):metadata.adopt 显性优先,否则按是否在脚本里推导;
|
||||||
|
# 商品组永远保留(不受采用态影响)。顺带把不在脚本里的(含三视图组 label「·三视图」)排除,
|
||||||
|
# 避免兜底误抓未采用/三视图组。
|
||||||
|
_entity_names = {(e.get("name") or "").strip() for e in entities.values() if (e.get("name") or "").strip()}
|
||||||
|
|
||||||
|
def _group_adopted(g) -> bool:
|
||||||
|
if g.kind == BaseAssetGroup.Kind.PRODUCT:
|
||||||
|
return True
|
||||||
|
a = (g.metadata or {}).get("adopt")
|
||||||
|
if a == "adopted":
|
||||||
|
return True
|
||||||
|
if a == "unadopted":
|
||||||
|
return False
|
||||||
|
return (g.metadata or {}).get("label", "").strip() in _entity_names
|
||||||
|
|
||||||
|
groups = [g for g in groups if _group_adopted(g)]
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
used: set = set()
|
used: set = set()
|
||||||
for rid in (segment.entity_refs or []):
|
for rid in (segment.entity_refs or []):
|
||||||
|
|||||||
@@ -532,3 +532,36 @@ class ExportClipsTests(TestCase):
|
|||||||
def test_export_empty_returns_400(self):
|
def test_export_empty_returns_400(self):
|
||||||
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
|
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
|
||||||
self.assertEqual(res.status_code, 400)
|
self.assertEqual(res.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAssetAdoptDeleteTests(TestCase):
|
||||||
|
"""显性采用/未采用 + 删除角色组。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(username="bad", password="x")
|
||||||
|
self.team = Team.objects.create(name="BAD", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||||
|
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||||
|
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
|
||||||
|
self.group = BaseAssetGroup.objects.create(project=self.proj, kind=BaseAssetGroup.Kind.PERSON, metadata={"label": "小明"})
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def test_set_adopt_state(self):
|
||||||
|
res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "unadopted"}, format="json")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.group.refresh_from_db()
|
||||||
|
self.assertEqual(self.group.metadata.get("adopt"), "unadopted")
|
||||||
|
|
||||||
|
def test_set_adopt_invalid_state(self):
|
||||||
|
res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "x"}, format="json")
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
|
||||||
|
def test_delete_base_asset(self):
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
res = self.client.post(f"/api/projects/{self.proj.id}/delete-base-asset/", {"group_id": str(self.group.id)}, format="json")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertFalse(BaseAssetGroup.objects.filter(id=self.group.id).exists())
|
||||||
|
|||||||
@@ -362,6 +362,38 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
promote_base_asset_stage_if_ready(project)
|
promote_base_asset_stage_if_ready(project)
|
||||||
return Response(BaseAssetGroupSerializer(group).data)
|
return Response(BaseAssetGroupSerializer(group).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="set-base-asset-adopt")
|
||||||
|
@transaction.atomic
|
||||||
|
def set_base_asset_adopt(self, request, pk=None):
|
||||||
|
"""显性「采用 / 未采用」某角色/场景组(metadata.adopt)。下游只取已采用的;脚本里没有的默认未采用,
|
||||||
|
但用户强制采用随他。默认态(不写 adopt)由前端按是否在脚本里推导,这里只存用户的覆盖。"""
|
||||||
|
project = self.get_object()
|
||||||
|
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first()
|
||||||
|
if group is None:
|
||||||
|
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
state = request.data.get("state")
|
||||||
|
if state not in ("adopted", "unadopted"):
|
||||||
|
return Response({"detail": "state must be adopted/unadopted"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
meta = dict(group.metadata or {})
|
||||||
|
meta["adopt"] = state
|
||||||
|
group.metadata = meta
|
||||||
|
group.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return Response(BaseAssetGroupSerializer(group).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="delete-base-asset")
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_base_asset(self, request, pk=None):
|
||||||
|
"""删除某角色/场景资产组(连同它的三视图配套组一并删)。资产本身留在资产表,只移除项目内的组引用。"""
|
||||||
|
project = self.get_object()
|
||||||
|
group = BaseAssetGroup.objects.filter(project=project, id=request.data.get("group_id")).first()
|
||||||
|
if group is None:
|
||||||
|
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
# 连带删该角色立绘配套的三视图组(triview_of 指向本组采用的立绘)
|
||||||
|
if group.adopted_asset_id:
|
||||||
|
project.base_asset_groups.filter(metadata__triview_of=str(group.adopted_asset_id)).delete()
|
||||||
|
group.delete()
|
||||||
|
return Response({"deleted": True})
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="attach-base-asset")
|
@action(detail=True, methods=["post"], url_path="attach-base-asset")
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def attach_base_asset(self, request, pk=None):
|
def attach_base_asset(self, request, pk=None):
|
||||||
|
|||||||
@@ -897,6 +897,8 @@ export function App() {
|
|||||||
}, "故事板已生成")
|
}, "故事板已生成")
|
||||||
}
|
}
|
||||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||||
|
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
|
||||||
|
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
|
||||||
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
|
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
|
||||||
onGenerateTriview={async (portraitAssetId) => {
|
onGenerateTriview={async (portraitAssetId) => {
|
||||||
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
|
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
|
||||||
|
|||||||
@@ -364,6 +364,14 @@ export const api = {
|
|||||||
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||||
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
|
// 显性采用/未采用某角色/场景组(下游只取已采用)
|
||||||
|
setBaseAssetAdopt(projectId: string, payload: { group_id: string; state: "adopted" | "unadopted" }) {
|
||||||
|
return request(`/api/projects/${projectId}/set-base-asset-adopt/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
// 删除某角色/场景资产组(连带其三视图组)
|
||||||
|
deleteBaseAsset(projectId: string, groupId: string) {
|
||||||
|
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
|
||||||
|
},
|
||||||
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
|
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
|
||||||
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
|
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
|
||||||
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
|
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
|
||||||
|
|||||||
@@ -860,3 +860,19 @@
|
|||||||
.pipeline-reextract { position: absolute; top: 14px; right: 28px; z-index: 5; }
|
.pipeline-reextract { position: absolute; top: 14px; right: 28px; z-index: 5; }
|
||||||
/* 提取闸门单按钮:醒目主 CTA */
|
/* 提取闸门单按钮:醒目主 CTA */
|
||||||
.eg-extract-btn { margin-top: 24px; min-width: 220px; }
|
.eg-extract-btn { margin-top: 24px; min-width: 220px; }
|
||||||
|
|
||||||
|
/* C:角色/场景卡 采用/未采用 toggle */
|
||||||
|
.adopt-toggle { height: 22px; padding: 0 9px; border-radius: var(--r-pill); font-size: 11.5px; font-weight: 500; border: 1px solid transparent; cursor: pointer; white-space: nowrap; letter-spacing: .02em; }
|
||||||
|
.adopt-toggle.adopted { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); }
|
||||||
|
.adopt-toggle.unadopted { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); }
|
||||||
|
.adopt-toggle:hover { border-color: var(--black-alpha-24); }
|
||||||
|
/* D:角色/场景卡 hover 删除 + 二次确认 */
|
||||||
|
.asset-card-2 { position: relative; }
|
||||||
|
.asset-card-del { position: absolute; top: 8px; right: 8px; z-index: 3; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border: none; border-radius: var(--r-sm); background: var(--black-alpha-48); color: var(--accent-white); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
|
||||||
|
.asset-card-del svg { width: 14px; height: 14px; }
|
||||||
|
.asset-card-2:hover .asset-card-del { opacity: 1; }
|
||||||
|
.asset-card-del:hover { background: var(--accent-crimson); }
|
||||||
|
.asset-card-del-confirm { position: absolute; top: 8px; right: 8px; z-index: 3; display: inline-flex; gap: 6px; }
|
||||||
|
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
|
||||||
|
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
|
||||||
|
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
|
||||||
|
|||||||
@@ -469,6 +469,8 @@ export function PipelinePage(props: {
|
|||||||
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
|
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
|
||||||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
|
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
|
||||||
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||||
|
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
|
||||||
|
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
|
||||||
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
|
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
|
||||||
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
|
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
|
||||||
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
|
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
|
||||||
@@ -495,7 +497,7 @@ export function PipelinePage(props: {
|
|||||||
const {
|
const {
|
||||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||||
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||||
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
|
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
|
||||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
} = props;
|
} = props;
|
||||||
@@ -613,6 +615,18 @@ export function PipelinePage(props: {
|
|||||||
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
|
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
|
||||||
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
|
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
|
||||||
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
|
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
|
||||||
|
// 采用态:metadata.adopt 显性优先;否则按"实体名是否在脚本里"推导(在脚本→已采用,孤儿→未采用)
|
||||||
|
const scriptEntityNames = useMemo(
|
||||||
|
() => new Set(((project.metadata?.script_entities as Array<{ name?: string }>) ?? []).map((e) => (e?.name || "").trim()).filter(Boolean)),
|
||||||
|
[project.metadata?.script_entities]
|
||||||
|
);
|
||||||
|
function adoptStateOf(group: BaseGroup, name: string): "adopted" | "unadopted" {
|
||||||
|
const a = (group?.metadata as { adopt?: string } | undefined)?.adopt;
|
||||||
|
if (a === "adopted" || a === "unadopted") return a;
|
||||||
|
return scriptEntityNames.has((name || "").trim()) ? "adopted" : "unadopted";
|
||||||
|
}
|
||||||
|
// 删除二次确认:点删除先 arm(显"确认删除"),再点才真删;3s 自动撤销
|
||||||
|
const [delArmed, setDelArmed] = useState<string | null>(null);
|
||||||
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
|
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
|
||||||
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
|
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
|
||||||
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
|
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
|
||||||
@@ -778,19 +792,7 @@ export function PipelinePage(props: {
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [adDetail]);
|
}, [adDetail]);
|
||||||
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
|
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
|
||||||
const triAutoRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
if (!adDetail || adDetail.kind !== "person") return;
|
|
||||||
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
|
|
||||||
if (!ent || isBusy(`addet-tri:${ent.key}`)) return;
|
|
||||||
const portraitAsset = adPortraitId || ent.group.adopted_asset;
|
|
||||||
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
|
|
||||||
if (triAutoRef.current.has(portraitAsset)) return;
|
|
||||||
triAutoRef.current.add(portraitAsset);
|
|
||||||
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [adDetail, adPortraitId]);
|
|
||||||
|
|
||||||
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
|
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
|
||||||
const storyboards = project.storyboard_versions ?? [];
|
const storyboards = project.storyboard_versions ?? [];
|
||||||
@@ -2589,7 +2591,7 @@ export function PipelinePage(props: {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="eg-title">先从剧本认出角色 / 场景</div>
|
<div className="eg-title">先从剧本认出角色 / 场景</div>
|
||||||
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成,不自动花钱</div>
|
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
|
||||||
<button type="button" className="btn btn-primary btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>提取角色 / 场景</button>
|
<button type="button" className="btn btn-primary btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>提取角色 / 场景</button>
|
||||||
{extractErr && <div className="eg-err">{extractErr}</div>}
|
{extractErr && <div className="eg-err">{extractErr}</div>}
|
||||||
</>
|
</>
|
||||||
@@ -2780,7 +2782,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}>替换</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2801,6 +2803,17 @@ export function PipelinePage(props: {
|
|||||||
const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
|
const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
|
||||||
return (
|
return (
|
||||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||||
|
{/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */}
|
||||||
|
{grp.id && (delArmed === grp.id ? (
|
||||||
|
<span className="asset-card-del-confirm" data-stop>
|
||||||
|
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setDelArmed(null); void onDeleteBaseAsset(grp.id); }}>确认删除</button>
|
||||||
|
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmed(null); }}>取消</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setDelArmed(grp.id); window.setTimeout(() => setDelArmed((v) => (v === grp.id ? null : v)), 3000); }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||||
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
|
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
|
||||||
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
|
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
|
||||||
@@ -2820,10 +2833,14 @@ export function PipelinePage(props: {
|
|||||||
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
|
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* C:采用/未采用 toggle —— 下游只取已采用;脚本里没有的默认未采用,可点采用。需已有真实组 */}
|
||||||
|
{grp.id && grp.adopted_asset && (() => { const st = adoptStateOf(grp, entity.name); return (
|
||||||
|
<button className={`adopt-toggle ${st}`} type="button" data-stop title={st === "adopted" ? "已采用 · 点击改为未采用" : "未采用 · 点击采用(下游只取已采用的)"} onClick={(e) => { e.stopPropagation(); void onSetAdoptState(grp.id, st === "adopted" ? "unadopted" : "adopted"); }}>{st === "adopted" ? "已采用" : "未采用"}</button>
|
||||||
|
); })()}
|
||||||
</div>
|
</div>
|
||||||
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
|
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
|
||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2848,7 +2865,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
|
<div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
|
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
|
||||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
<button className="btn btn-primary btn-lg" type="button" onClick={() => guardGen(shots, () => goStage(3))}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3565,9 +3582,8 @@ export function PipelinePage(props: {
|
|||||||
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
|
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
|
||||||
async function regenPortrait() {
|
async function regenPortrait() {
|
||||||
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
||||||
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
|
// 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出
|
||||||
if (isPerson) await genPersonWithTri(prompt, entity!.name, pBK);
|
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK);
|
||||||
else await genBaseAsset("scene", prompt, entity!.name, pBK);
|
|
||||||
setAdPortraitId(null); setAdTriId(null);
|
setAdPortraitId(null); setAdTriId(null);
|
||||||
}
|
}
|
||||||
async function regenTri() {
|
async function regenTri() {
|
||||||
@@ -3637,7 +3653,7 @@ export function PipelinePage(props: {
|
|||||||
{!triUrl && !busyTri && (
|
{!triUrl && !busyTri && (
|
||||||
<div className="asset-detail-tip">
|
<div className="asset-detail-tip">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||||
<span>三视图据立绘自动生成,以保证正/侧/背多角度一致</span>
|
<span>三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。</span>
|
||||||
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
import path from "node:path"; import fs from "node:fs";
|
||||||
|
const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true});
|
||||||
|
const b=await chromium.launch({headless:true}); const r={consoleErrors:[]};
|
||||||
|
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
|
||||||
|
p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())}); p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message));
|
||||||
|
await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300);
|
||||||
|
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("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200);
|
||||||
|
await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200);
|
||||||
|
r.adoptToggles=await p.locator(".adopt-toggle").count();
|
||||||
|
r.toggleTexts=await p.locator(".adopt-toggle").allInnerTexts().catch(()=>[]);
|
||||||
|
// hover 第一张角色卡看删除按钮
|
||||||
|
const card=p.locator(".asset-card-2[data-asset-kind='person']").first();
|
||||||
|
if(await card.count()){ await card.hover(); await p.waitForTimeout(500); r.delBtnVisible=await card.locator(".asset-card-del").isVisible().catch(()=>false); }
|
||||||
|
await p.screenshot({path:path.join(OUT,"adopt-del.png"),fullPage:true});
|
||||||
|
await b.close(); console.log(JSON.stringify(r,null,2));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
import path from "node:path"; import fs from "node:fs";
|
||||||
|
const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true});
|
||||||
|
const b=await chromium.launch({headless:true}); const r={};
|
||||||
|
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
|
||||||
|
await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300);
|
||||||
|
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("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200);
|
||||||
|
await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200);
|
||||||
|
r.delBtnInDom=await p.locator(".asset-card-del").count();
|
||||||
|
// hover 含 toggle 的那张真实角色卡
|
||||||
|
const realCard=p.locator(".asset-card-2:has(.adopt-toggle)").first();
|
||||||
|
r.realCardCount=await realCard.count();
|
||||||
|
if(await realCard.count()){
|
||||||
|
await realCard.hover(); await p.waitForTimeout(400);
|
||||||
|
const opacity=await realCard.locator(".asset-card-del").evaluate(el=>getComputedStyle(el).opacity).catch(()=>"n/a");
|
||||||
|
r.delOpacityOnHover=opacity;
|
||||||
|
await realCard.screenshot({path:path.join(OUT,"card-hover.png")});
|
||||||
|
}
|
||||||
|
await b.close(); console.log(JSON.stringify(r,null,2));
|
||||||
Reference in New Issue
Block a user