diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index a0b37bd..c8a9e04 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -561,7 +561,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str, return asset -def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "") -> BaseAssetGroup: +def _find_entity_group(project, kind: str, label: str, group_id: str | None): + """复用同一实体的基础资产组(版本=candidate_assets,采用=adopted_asset)。 + 优先 group_id;商品=该项目唯一商品组;人物/场景按 label 命中非三视图组;都没有则 None(新建)。""" + if group_id: + return project.base_asset_groups.filter(id=group_id).first() + candidates = list(project.base_asset_groups.filter(kind=kind).order_by("created_at")) + candidates = [g for g in candidates if not (g.metadata or {}).get("triview_of")] # 三视图组不算实体本体 + if kind == BaseAssetGroup.Kind.PRODUCT: + return candidates[0] if candidates else None + lbl = (label or "").strip() + if lbl: + return next((g for g in candidates if (g.metadata or {}).get("label") == lbl), None) + return None + + +def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> BaseAssetGroup: model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") @@ -604,12 +619,16 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " category=category, asset_type=Asset.Type.IMAGE, ) - # label = 该资产对应的脚本提取标签(人物/场景名),用于把生成结果归回对应的标签卡 - group_meta = {"label": label.strip()} if label and label.strip() else {} - group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta) + # 复用同实体的组:追加候选 + 采用最新(版本=candidate_assets,采用=adopted_asset);无则新建 + group = _find_entity_group(project, kind, label, group_id) + if group is None: + group_meta = {"label": label.strip()} if label and label.strip() else {} + group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta) + elif prompt: + group.prompt = prompt group.candidate_assets.add(asset) group.adopted_asset = asset - group.save(update_fields=["adopted_asset", "updated_at"]) + group.save(update_fields=["adopted_asset", "prompt", "updated_at"]) # 真人资产:事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务) if kind == BaseAssetGroup.Kind.PERSON: from apps.assets.review import submit_asset_for_review @@ -625,16 +644,14 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " raise -def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup": - """流程步骤4 · 据「某一版立绘」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。 - 三视图与立绘 1:1 绑定:新立绘 → 新三视图。落一个 metadata.triview_of=<立绘组id> 的 person 组。""" +def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup": + """流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。 + 三视图与立绘 1:1 绑定:metadata.triview_of=<立绘 asset id>;同一立绘多次=同组追加候选(版本)。""" from apps.ai.model_library import THREE_VIEW_PROMPT - if portrait_group.kind != BaseAssetGroup.Kind.PERSON: - raise ValueError("三视图仅用于人物立绘") - portrait_asset = portrait_group.adopted_asset if portrait_asset is None: raise ValueError("该立绘尚未生成,无法据它生成三视图") + asset_key = str(portrait_asset.id) model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") @@ -642,7 +659,7 @@ def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup if not hasattr(provider, "image_edit"): raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)") ref_url = _asset_preview_url(portrait_asset) - payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": str(portrait_group.id)} + payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key} task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload) reservation = task.credit_reservation try: @@ -659,11 +676,14 @@ def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup team=project.team, user=user, project=project, task=task, media=media, name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE, ) - # label 带立绘组 id:前端据此把三视图归到对应立绘版本(切立绘 → 切三视图) - group = BaseAssetGroup.objects.create( - project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT, - metadata={"label": f"{portrait_group.id} ·三视图", "triview_of": str(portrait_group.id)}, - ) + # 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新 + group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at") + if (g.metadata or {}).get("triview_of") == asset_key), None) + if group is None: + group = BaseAssetGroup.objects.create( + project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT, + metadata={"label": "·三视图", "triview_of": asset_key}, + ) group.candidate_assets.add(asset) group.adopted_asset = asset group.save(update_fields=["adopted_asset", "updated_at"]) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index e2158bd..c0f1138 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -275,13 +275,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["post"], url_path="generate-triview") def generate_triview(self, request, pk=None): - """流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性)。""" + """流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,三视图绑定该立绘)。""" project = self.get_object() - portrait = BaseAssetGroup.objects.filter(project=project, id=request.data.get("portrait_group_id")).first() - if portrait is None: - return Response({"detail": "portrait group not found"}, status=status.HTTP_404_NOT_FOUND) + portrait_asset = Asset.objects.filter(team=project.team, id=request.data.get("portrait_asset_id")).first() + if portrait_asset is None: + return Response({"detail": "portrait asset not found"}, status=status.HTTP_404_NOT_FOUND) try: - group = generate_person_triview(project=project, user=request.user, portrait_group=portrait) + group = generate_person_triview(project=project, user=request.user, portrait_asset=portrait_asset) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2e7ab54..3498a02 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -711,7 +711,7 @@ export function App() { } onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")} - onGenerateTriview={(portraitGroupId) => action(() => api.generateTriview(pipelineProject.id, { portrait_group_id: portraitGroupId }), "三视图已据立绘生成")} + onGenerateTriview={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")} onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })} onUploadActor={(file) => { const fd = new FormData(); diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 1602810..2209b27 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -308,8 +308,8 @@ export const api = { attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, - // 流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘组) - generateTriview(projectId: string, payload: { portrait_group_id: string }) { + // 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset) + generateTriview(projectId: string, payload: { portrait_asset_id: string }) { return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) }); }, // 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章 diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 6131c4a..6350dcf 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -268,6 +268,22 @@ .prod-preview-img { aspect-ratio: 16/9; } .prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; } .prod-preview-history { display: none; flex-direction: column; gap: 6px; } + .prod-preview-history.show { display: flex; } + .prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; } + .prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; } + .prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; } + .prod-preview-history .h-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; transition: border-color var(--t-base); display: grid; place-items: center; overflow: hidden; } + .prod-preview-history .h-thumb:hover { border-color: var(--heat-40); } + .prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); } + .prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; } + .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; position: relative; z-index: 1; } + .prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; } + .prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; } + .prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 9px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; z-index: 2; } + .prod-preview-history .h-thumb.adopted .badge { display: block; } + .prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); } + .prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); } + .prod-preview-adopt:disabled, .prod-preview-adopt:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); cursor: not-allowed; opacity: 1; } .asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; } .asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); } @@ -276,6 +292,12 @@ .asset-card-2.asset-seed .thumb-2 { opacity: .7; } .asset-card-2 .thumb-2 { aspect-ratio: 1; } .asset-card-2 .body-2 { padding: 12px 14px; } + /* 对齐设计稿:人物/场景卡提示词框(mono 可编辑,非 textarea) */ + .prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); } + .prompt-box[contenteditable="true"] { cursor: text; outline: none; } + .prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); } + .prompt-box[contenteditable="true"]:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); } + .asset-card-2 .thumb-2 .spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; } .asset-card-2 .body-2 .btn:disabled, .asset-card-2 .body-2 .btn.disabled { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; cursor: not-allowed; opacity: .72; transform: none; } .asset-card-2 .body-2 .btn:disabled:hover, .asset-card-2 .body-2 .btn.disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; transform: none; } @@ -572,6 +594,51 @@ .tri-cand-card .tri-cand-img { aspect-ratio: 16/9; } .tri-cand-card .tri-cand-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; } .tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); } + /* ── 流程步骤4 · 人物/场景详情弹窗(对齐设计稿 asset-detail-grid)── */ + .asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; } + .asset-detail-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; } + .asset-detail-lead { display: flex; flex-direction: column; gap: 10px; } + .asset-detail-lead .ad-lead-wrap { position: relative; } + .asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); } + .ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; } + .ad-zoom-btn:hover { background: rgba(21,20,15,.92); } + .ad-zoom-btn svg { width: 14px; height: 14px; } + .asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn, .asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; } + .asset-detail-tri-row .ad-tri-wrap { position: relative; } + .asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; } + .asset-detail-lead .ad-thumbs .thumb { flex: 0 0 56px; width: 56px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; transition: border-color var(--t-base); } + .asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); } + .asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; } + .asset-detail-right .ad-section + .ad-section { margin-top: 18px; } + .asset-detail-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; } + .asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; display: grid; place-items: center; } + .asset-detail-section-h .ic svg { width: 14px; height: 14px; } + .asset-detail-section-h .ad-ratio-chip { margin-left: auto; font-family: var(--font-mono); font-size: 12px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); border: 1px solid var(--border-faint); color: var(--black-alpha-56); letter-spacing: .02em; } + .asset-detail-section-h .ad-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); } + .asset-detail-section-h .ad-icon-btn:hover { color: var(--heat); border-color: var(--heat-40); } + .asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; } + .asset-detail-section-h .ad-icon-gap { margin-left: 8px; } + .asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); } + .asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; gap: 8px; } + .md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; } + .md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); } + .md-view-versions .v-thumb:hover { border-color: var(--heat-40); } + .md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); } + .md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; } + .md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; } + .asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; } + .asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; } + .asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12px; cursor: pointer; font-family: inherit; flex-shrink: 0; } + .asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; } + .asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); } + .asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); } + .asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; } + .ad-detail-prompt { width: 100%; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; outline: none; resize: vertical; min-height: 64px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); } + /* 版本缩略图(非 .placeholder,需自己接 --mock-media-url 才显图) */ + .ad-thumbs .thumb.has-mock-media, .md-view-versions .v-thumb.has-mock-media, .prod-preview-history .h-thumb.has-mock-media { background-image: var(--mock-media-url); background-size: cover; background-position: center; background-repeat: no-repeat; } + .ad-detail-prompt:hover { border-color: var(--heat-20); } + .ad-detail-prompt:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); } + .tri-empty { text-align: center; padding: 8px 0; } .tri-empty .tri-empty-hint { font-size: 13px; line-height: 1.6; color: var(--black-alpha-56); margin-top: 14px; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 6cb178c..e1140a4 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -488,10 +488,10 @@ export function PipelinePage(props: { const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product"); // 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上) const [assetPromptDraft, setAssetPromptDraft] = useState>({}); - // 行39 · AI 生成三视图弹窗:记录每次生成的三视图版本(每次生成=一个 product group),可切换查看 + 重跑 + 采用 - const [triViewOpen, setTriViewOpen] = useState(false); - const [triSelId, setTriSelId] = useState(null); // 弹窗里当前选中查看/采用的三视图版本(product group id) - useBodyScrollLock(triViewOpen); + // 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗) + // 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group + const [triPanelOpen, setTriPanelOpen] = useState(false); + const [triPreviewId, setTriPreviewId] = useState(null); function jumpAssetSection(kind: "product" | "person" | "scene") { setAssetTab(kind); if (typeof document !== "undefined") { @@ -499,58 +499,55 @@ export function PipelinePage(props: { } } - // ── 流程步骤4 · 人物/场景「实体」聚合(前端优先):后端每次生成新建一个 group, - // 这里把同一脚本标签(metadata.label)的多个 group 合成「一个角色的多版本立绘」。 - // 三视图与「某一版立绘」1:1 绑定:三视图组带 metadata.triview_of=<立绘组id>,从主网格过滤掉。 + // ── 流程步骤4 · 正确模型:一个实体 = 一个 BaseAssetGroup,candidate_assets = 各版本,adopted_asset = 采用/当前 ── + // 人物立绘版本 = 人物组的 candidate_assets;商品三视图版本 = 商品组的 candidate_assets。 + // 人物「某一版立绘的三视图」= 另一个 person 组,metadata.triview_of = <该立绘 asset id>,其 candidate_assets = 三视图版本。 type BaseGroup = (typeof groups)[number]; const triviewOf = (g?: BaseGroup | null): string => (g?.metadata?.triview_of as string) || ""; const isTriview = (g: BaseGroup) => Boolean(triviewOf(g)); - // 某一版立绘(portraitGroupId)配套的三视图版本(按 created_at 升序,新的在后) - function triviewsFor(portraitGroupId: string): BaseGroup[] { - return groupsByKind("person").filter((g) => triviewOf(g) === portraitGroupId); - } - type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; portraits: BaseGroup[] }; - // 聚合一个 kind 下的实体列表(立绘按 label 合并版本;无 label 的各自独立成卡;三视图组不计入) + // 某一版立绘资产(assetId)配套的三视图组(同一立绘只一组,内含多版本候选) + const triGroupForAsset = (assetId?: string | null): BaseGroup | 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[] }; + // 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入 function buildEntities(kind: "person" | "scene"): AssetEntity[] { const portraits = groupsByKind(kind).filter((g) => !isTriview(g)); - const map = new Map(); - for (const g of portraits) { + const byLabel = new Map(); + for (const g of portraits) byLabel.set((g.metadata?.label || "").trim() || g.id, g); // 升序遍历,后者(更新)覆盖 + return [...byLabel.entries()].map(([key, g], i) => { const label = (g.metadata?.label || "").trim(); - const key = label || g.id; - if (!map.has(key)) map.set(key, { key, label, portraits: [] }); - map.get(key)!.portraits.push(g); - } - return [...map.values()].map((e, i) => { - const name = e.label || assetName(e.portraits.at(-1)?.adopted_asset) || `${KIND_LABEL[kind]} ${i + 1}`; - return { key: e.key, label: e.label, name, kind, portraits: e.portraits }; + const name = label || assetName(g.adopted_asset) || `${KIND_LABEL[kind]} ${i + 1}`; + return { key, label, name, kind, group: g, versions: g.candidate_assets ?? [] }; }); } // 行38/流程步骤4 · 单卡生成 loading:action() 全局串行,这里只记「当前哪张卡在生成」做局部转圈 const [genBusyKey, setGenBusyKey] = useState(null); - async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise<{ id?: string } | null> { + type GenResult = { id?: string; adopted_asset?: string | null } | null; + async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise { if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心) setGenBusyKey(busyKey); try { - return (await onGenerateBaseAsset(kind, prompt, label)) as { id?: string } | null; + return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult; } finally { setGenBusyKey(null); } } - // 据某一版立绘生成它配套的三视图(loading 用 busyKey) - async function genTriview(portraitGroupId: string, busyKey: string): Promise<{ id: string } | null> { + // 据某一版立绘资产生成它配套的三视图(loading 用 busyKey) + async function genTriview(portraitAssetId: string, busyKey: string): Promise<{ id: string } | null> { if (genBusyKey) return null; setGenBusyKey(busyKey); try { - return await onGenerateTriview(portraitGroupId); + return await onGenerateTriview(portraitAssetId); } finally { setGenBusyKey(null); } } - // 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘 → 据它生成新三视图 + // 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图 async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) { const g = await genBaseAsset("person", prompt, label, busyKey); - if (g?.id) await genTriview(g.id, `${busyKey}:tri`); + const newAsset = g?.adopted_asset; + if (newAsset) await genTriview(newAsset, `${busyKey}:tri`); return g; } // 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签) @@ -570,21 +567,20 @@ export function PipelinePage(props: { // ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── const [adDetail, setAdDetail] = useState<{ kind: "person" | "scene"; key: string } | null>(null); - const [adPortraitId, setAdPortraitId] = useState(null); // 当前查看的立绘 group - const [adTriId, setAdTriId] = useState(null); // 当前查看的三视图 group + const [adPortraitId, setAdPortraitId] = useState(null); // 当前查看的立绘 asset id(组内候选) + const [adTriId, setAdTriId] = useState(null); // 当前查看的三视图 asset id const [adPrompt, setAdPrompt] = useState(""); useBodyScrollLock(Boolean(adDetail)); function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) { setAdDetail({ kind, key: entity.key }); - setAdPortraitId(entity.portraits.at(-1)?.id ?? null); + setAdPortraitId(entity.group.adopted_asset ?? entity.versions.at(-1) ?? null); setAdTriId(null); // 跟随当前立绘的最新三视图 - setAdPrompt(entity.portraits.at(-1)?.prompt ?? ""); + setAdPrompt(entity.group.prompt ?? ""); } - // 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体最新立绘组(选演员后挂为候选并采用) + // 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体组(选演员后挂为候选并采用) const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null); function openActorReplace(entity: AssetEntity) { - const gid = entity.portraits.at(-1)?.id; - if (gid) setActorLib({ mode: "replace", groupId: gid }); + setActorLib({ mode: "replace", groupId: entity.group.id }); } async function pickActor(assetId: string) { if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId); @@ -596,17 +592,17 @@ export function PipelinePage(props: { window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [adDetail]); - // 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘还没三视图就自动据它生成一版(每立绘组仅一次) + // 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次) const triAutoRef = useRef>(new Set()); useEffect(() => { if (!adDetail || adDetail.kind !== "person" || genBusyKey) return; const ent = buildEntities("person").find((e) => e.key === adDetail.key); - if (!ent || !ent.portraits.length) return; - const portrait = ent.portraits.find((g) => g.id === adPortraitId) || ent.portraits.at(-1)!; - if (!portrait.adopted_asset || triviewsFor(portrait.id).length) return; - if (triAutoRef.current.has(portrait.id)) return; - triAutoRef.current.add(portrait.id); - void genTriview(portrait.id, `addet-tri:${ent.key}`); + if (!ent) 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]); // 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载) @@ -2212,12 +2208,17 @@ export function PipelinePage(props: { {/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */} {viewStage === 2 && (() => { // 流程步骤4 · 每次生成三视图 = 一个新 product group;聚合成「版本列表」,可切换 + 重跑 + 采用 - const productGroups = groupsByKind("product").filter((g) => g.adopted_asset); - const latestTri = productGroups.at(-1) || null; - const productAssetUrl = groupMainUrl(latestTri) || assetUrl(productCover); - const hasTriView = productGroups.length > 0; + // 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版 + const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null; + const productVersions = productGroup?.candidate_assets ?? []; + const triGenerating = genBusyKey === "tri:product"; + const adoptedTriAsset = productGroup?.adopted_asset || ""; + const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset; + const hasTriView = productVersions.length > 0; + const triPanelShow = triPanelOpen || hasTriView || triGenerating; + const productAssetUrl = assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板 const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`; - const triSelGroup = productGroups.find((g) => g.id === triSelId) || latestTri; + const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); }; return (
@@ -2244,7 +2245,7 @@ export function PipelinePage(props: {

商品 · {productName}

-
+
{!hasTriView && ( @@ -2268,20 +2269,53 @@ export function PipelinePage(props: {
{(project.created_at || "").slice(0, 10)} 创建
- {/* 行39 · 点开弹窗:可选择采用哪个三视图版本(建议生成,但没有也可跑视频) */} -
-
-
// 三视图版本 · {productGroups.length} 版
-
{ setTriSelId(latestTri.id); setTriViewOpen(true); } : undefined}>最新版
-
- {[...productGroups].reverse().slice(0, 4).map((g) => ( -
{ setTriSelId(g.id); setTriViewOpen(true); }}>
- ))} + {/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */} +
+
// 三视图预览 · {triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}
+ {(() => { + const u = candUrl(productGroup, previewTriAsset); + if (triGenerating && !u) { + return
生成中
; + } + return ( +
setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}> + {!u && // 暂无三视图} +
+ ); + })()} + {previewTriAsset && ( +
+ + + + ~¥0.30 / 次 +
+ )} +
+
// 历史版本 · {productVersions.length}
+
+ {productVersions.map((assetId, i) => { + const isAdopted = assetId === adoptedTriAsset; + const isPreview = assetId === previewTriAsset; + const u = candUrl(productGroup, assetId); + return ( +
setTriPreviewId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTriPreviewId(assetId); } }}> + 已采用 + v{i + 1} +
+ ); + })} +
@@ -2307,18 +2341,14 @@ export function PipelinePage(props: { return (
-

{KIND_LABEL[kind]} · {entities.length}{tags.length ? ` / ${tags.length}` : ""} 个

+

{KIND_LABEL[kind]} · {entities.length} 个

- {kind === "person" && } - + {/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */} +
- {/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成(进入页面会自动补齐) */} - {pendingTags.length > 0 && ( -
- {pendingTags.map((tag) => { +
+ {/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */} + {pendingTags.map((tag) => { const seedKey = `seed:${kind}:${tag}`; const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag); const busy = genBusyKey === seedKey; @@ -2326,94 +2356,60 @@ export function PipelinePage(props: {
{busy - ? 生成中… + ?
生成中 · 约 8s
: {tag} · 待生成}
-
- {tag} +
{tag}来自脚本
+
setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}
+
- 来自脚本 -
-