From a9b7f077b95add15cf4d54c2076cd867ca4cba4a Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 12:00:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=9F=BA=E7=A1=80=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E9=A1=B5=E6=8C=89=E6=B5=81=E7=A8=8B=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=A1=A5=E5=AE=8C=20step4(=E4=BA=BA=E7=89=A9/=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E8=AF=A6=E6=83=85+=E6=BC=94=E5=91=98=E5=BA=93+?= =?UTF-8?q?=E4=B8=89=E8=A7=86=E5=9B=BE=E7=BB=91=E5=AE=9A=E7=AB=8B=E7=BB=98?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人物/场景: - 卡片可点开详情弹窗:立绘大图(hover 右下角 下载/查看大图)+ 立绘版本切换 + 提示词重跑(覆盖卡片立绘,归入同角色)+ 应用到当前项目;场景详情不显示三视图 - 进入基础资产自动生成脚本提取的待生成人物/场景(带 loading),每项目一次 - 「替换」→ 演员库 replace 回填;人物区头部「演员库」入口 三视图:与「某一版立绘」1:1 绑定(metadata.triview_of=立绘组id) - 生成/重跑立绘 → 链式据该立绘 image_edit 出配套三视图(锁角色一致性) - 详情切换立绘版本 → 三视图跟着切;可据当前立绘再出多版,下方版本小图可切 - 后端 generate_person_triview + 端点 generate-triview,复用 model_library 三视图 SOP 商品三视图弹窗:记录版本、切换查看、重跑生成、采用此版本 演员库(新建 components/actor-library.tsx 覆盖层): - 平台预设演员 / 我的演员两 tab + 添加演员工作台(AI 生成 / 本地上传保存) - 替换基础资产卡:新增后端 attach-base-asset(把现有资产挂为候选并采用) 合并 dev 进来的火山真人审核绿/红盾到新的实体卡。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ai/services.py | 52 ++ core/backend/apps/projects/views.py | 31 ++ core/frontend/src/App.tsx | 11 + core/frontend/src/api.ts | 8 + .../frontend/src/components/actor-library.tsx | 151 ++++++ core/frontend/src/pipeline-page.css | 46 ++ core/frontend/src/routes/pipeline.tsx | 464 +++++++++++++++--- 7 files changed, 690 insertions(+), 73 deletions(-) create mode 100644 core/frontend/src/components/actor-library.tsx diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 22475db..a0b37bd 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -625,6 +625,58 @@ 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 组。""" + 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("该立绘尚未生成,无法据它生成三视图") + model_config = get_default_model(ModelConfig.Capability.IMAGE) + if model_config is None: + raise ValueError("no active image model configured") + provider = get_image_provider(model_config) + 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)} + 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: + response = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[ref_url], size="1536x1024") + media = provider.extract_first_media_url(response) + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = response + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + 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, + ) + # 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)}, + ) + group.candidate_assets.add(asset) + group.adopted_asset = asset + group.save(update_fields=["adopted_asset", "updated_at"]) + return group + except Exception as exc: + task.status = AITask.Status.FAILED + task.error_message = str(exc) + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=reservation, reason=str(exc)) + raise + + def _scene_context(project) -> str: """从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。""" product = project.product diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 57167b9..e2158bd 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -19,6 +19,7 @@ from apps.ai.services import ( VOICEOVER_VOICES, create_export_job, generate_base_asset, + generate_person_triview, generate_project_script, generate_storyboard_frame, get_default_model, @@ -255,6 +256,36 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): promote_base_asset_stage_if_ready(project) return Response(BaseAssetGroupSerializer(group).data) + @action(detail=True, methods=["post"], url_path="attach-base-asset") + @transaction.atomic + def attach_base_asset(self, request, pk=None): + """流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。""" + 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) + asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first() + if asset is None: + return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND) + group.candidate_assets.add(asset) + group.adopted_asset_id = asset.id + group.save(update_fields=["adopted_asset", "updated_at"]) + promote_base_asset_stage_if_ready(project) + return Response(BaseAssetGroupSerializer(group).data) + + @action(detail=True, methods=["post"], url_path="generate-triview") + def generate_triview(self, request, pk=None): + """流程步骤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) + try: + group = generate_person_triview(project=project, user=request.user, portrait_group=portrait) + 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) + # ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ── def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None: diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 143dc50..2e7ab54 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -710,6 +710,17 @@ 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 }), "三视图已据立绘生成")} + onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })} + onUploadActor={(file) => { + const fd = new FormData(); + fd.append("file", file); + fd.append("name", file.name); + fd.append("asset_type", "image"); + fd.append("category", "person"); + return action(() => api.uploadAsset(fd), "演员已保存到演员库"); + }} onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")} onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")} onSubmitAllVideos={(prompt) => diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 5172443..1602810 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -304,6 +304,14 @@ export const api = { adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, + // 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用) + 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 }) { + return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) }); + }, // 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章 pollReviews(projectId: string) { return request<{ reviews: Record }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" }); diff --git a/core/frontend/src/components/actor-library.tsx b/core/frontend/src/components/actor-library.tsx new file mode 100644 index 0000000..eeb2800 --- /dev/null +++ b/core/frontend/src/components/actor-library.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { CSSProperties } from "react"; +import type { Asset } from "../types"; +import { useBodyScrollLock, MediaLightbox } from "./overlays"; + +// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。 +// 两种用法: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"; + +export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate, onUpload, onRefresh }: { + open: boolean; + mode: "browse" | "replace"; + assets: Asset[]; + onClose: () => void; + onPick?: (assetId: string) => void | Promise; + onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>; + onUpload: (file: File) => Promise; + onRefresh: () => void; +}) { + const [tab, setTab] = useState<"preset" | "mine">("preset"); + const [studio, setStudio] = useState(false); // 添加演员工作台 + const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai"); + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState(false); + const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); + const fileRef = useRef(null); + useBodyScrollLock(open); + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // person 类资产 → 演员;按 preset / mine 分两 tab + const people = useMemo(() => assets.filter((a) => a.category === "person" && previewOf(a)), [assets]); + const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a))); + + async function genActor() { + const p = prompt.trim() || "电商真人模特,自然光,干净背景,9:16 竖屏,正面半身"; + setBusy(true); + try { + await onGenerate(p); + onRefresh(); + setStudio(false); + setTab("mine"); + } finally { setBusy(false); } + } + async function pickFile(file?: File | null) { + if (!file) return; + setBusy(true); + try { + await onUpload(file); + onRefresh(); + setStudio(false); + setTab("mine"); + } finally { setBusy(false); } + } + + if (!open) return null; + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}

+ // {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"} + +
+ + {studio ? ( +
+
+ + 添加人物工作台 +
+
+ + +
+ {studioMode === "ai" ? ( +
+
// 描述演员形象(年龄 / 风格 / 妆造 / 背景)
+