From 4d2b9343b37d602ff0f23b7b792f0d7cec81a8c0 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 30 Jun 2026 13:20:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E9=87=8D=E8=B7=91?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=BD=93=E5=89=8D=E7=AB=8B=E7=BB=98+?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D,=E4=BF=9D=E6=8C=81=E4=BA=BA?= =?UTF-8?q?=E7=89=A9=E4=B8=80=E8=87=B4=20(row42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原「重跑」立绘是纯文生图,每次重抽成另一个随机人。改为:角色已有当前立绘时, 重跑传该立绘作参考图 → 后端走 image_edit(参考立绘+提示词),保持同一人物相貌/发型/身份一致。 - generate_base_asset 新增 reference_asset_id;person 有参考图则 image_edit + build_person_portrait_prompt_refs - run_base_asset_task image_edit size 按 kind 区分(person 立绘竖 1024x1536,商品三视图横) - 全链路透传 reference_asset_id:pipeline.regenPortrait(传 viewPortraitAsset)→ genBaseAsset → onGenerateBaseAsset → api.generateBaseAsset → generate-base-asset 端点 场景重跑不受影响(仍文生图);需部署生效(后端)。 构建: tsc=0/build=0; py_compile OK; projects+assets 测试无新增回归(基线 4 fail 2 err)。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ai/services.py | 38 ++++++++++++++++++++++----- core/backend/apps/projects/views.py | 2 +- core/frontend/src/App.tsx | 5 ++-- core/frontend/src/api.ts | 2 +- core/frontend/src/routes/pipeline.tsx | 12 +++++---- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 439d105..dbf192a 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1187,6 +1187,19 @@ def build_person_frontal_prompt(description: str = "") -> str: return render_prompt("person_portrait", default, 描述=desc) +def build_person_portrait_prompt_refs(description: str = "") -> str: + """角色立绘「重跑」refs 版:参考图=该角色当前立绘。保持同一人物的相貌/发型/身份不变, + 只据提示词微调并重绘为正面全身、纯色背景的电商真人模特立绘 —— 避免重跑重抽成另一个人。""" + desc = (description or "").strip() + base = ( + "参考图是该角色当前的立绘。保持参考图中人物的相貌、五官、发型、肤色与身份特征完全一致(同一个人)," + "重绘为电商真人模特氛围正面全身照,自然妆容,柔和影棚光,真实质感,单人,纯色背景。" + ) + if desc: + base += f"在保持人物一致的前提下,按以下要求调整:{desc}。" + return base + + # --------------------------------------------------------------------------- # # 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底) # --------------------------------------------------------------------------- # @@ -1219,7 +1232,7 @@ def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS): time.sleep(2 * (i + 1)) -def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask: +def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None, reference_asset_id: str | None = None) -> AITask: """提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级), 慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。 @@ -1234,9 +1247,19 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " # 商品三视图:有真实商品主图 → 走 image_edit 以主图为参考,锁定包装(品牌字/配色/外形/Logo)一致; # 无主图或当前模型不支持 image_edit → 回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。 product_ref_url = _product_cover_url(project.product) if kind == BaseAssetGroup.Kind.PRODUCT else "" - use_edit = bool(product_ref_url) and hasattr(provider, "image_edit") - if use_edit: + # 角色「重跑」:若该角色已有当前立绘(reference_asset_id),以它为参考图走 image_edit, + # 保持同一人物相貌一致(仅据立绘+提示词微调),不再重抽成另一个随机人。 + person_ref_url = "" + if kind == BaseAssetGroup.Kind.PERSON and reference_asset_id: + ref_asset = Asset.objects.filter(id=reference_asset_id, team=project.team, is_deleted=False).first() + if ref_asset is not None: + person_ref_url = _asset_preview_url(ref_asset) + ref_url = product_ref_url or person_ref_url + use_edit = bool(ref_url) and hasattr(provider, "image_edit") + if use_edit and product_ref_url: gen_prompt = build_product_triview_prompt_refs(project.product, prompt) + elif use_edit and person_ref_url: + gen_prompt = build_person_portrait_prompt_refs(prompt) # 角色重跑:参考当前立绘 + 提示词,保持人物一致 elif kind == BaseAssetGroup.Kind.PERSON: gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板 else: @@ -1253,7 +1276,7 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " payload = { "model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt, "kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "", - "use_edit": use_edit, "reference_image": product_ref_url, + "use_edit": use_edit, "reference_image": ref_url, } task = create_ai_task( project=project, @@ -1291,8 +1314,11 @@ def run_base_asset_task(*, task_id: str) -> None: try: def _make(): if use_edit and ref_url: - # 商品三视图:比例可在 admin 改(默认横) - size = prompt_ratio_size("product_triview", "1536x1024") + # 商品三视图默认横;角色立绘重跑(image_edit 参考当前立绘)默认竖,与文生图立绘一致。 + if kind == BaseAssetGroup.Kind.PERSON: + size = prompt_ratio_size("person_portrait", "1024x1536") + else: + size = prompt_ratio_size("product_triview", "1536x1024") resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size) else: # 场景默认横、人物立绘默认竖;两者比例都可在 admin 改 diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index facf643..812175a 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -487,7 +487,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if kind not in BaseAssetGroup.Kind.values: return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST) try: - task = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", "")) + task = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", ""), reference_asset_id=request.data.get("reference_asset_id") or None) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2967540..18827cd 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -965,9 +965,10 @@ export function App() { } onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")} onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")} - onGenerateBaseAsset={async (kind, prompt, label) => { + onGenerateBaseAsset={async (kind, prompt, label, referenceAssetId) => { // 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成 - const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成"); + // referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致 + const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }), "基础资产已生成"); return assetId ? { adopted_asset: assetId } : null; }} onGenerateStoryboard={(prompt) => diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 6da0be0..d3b951a 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -374,7 +374,7 @@ export const api = { return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) }); }, // 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢出图在 worker 跑,Web 不被占住) - generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string }) { + generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string; reference_asset_id?: string }) { return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 960b851..0018979 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -468,7 +468,7 @@ export function PipelinePage(props: { onSaveProjectMeta?: (meta: Record) => Promise; onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise; onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise; - onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise; + onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string, referenceAssetId?: string) => void | Promise; onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise; onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise; onDeleteBaseAsset: (groupId: string) => void | Promise; @@ -757,11 +757,11 @@ export function PipelinePage(props: { const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; }); const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; }); type GenResult = { id?: string; adopted_asset?: string | null } | null; - async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise { + async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise { if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发) addBusy(busyKey); try { - return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult; + return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult; } finally { delBusy(busyKey); } @@ -3973,8 +3973,10 @@ export function PipelinePage(props: { const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`); async function regenPortrait() { const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`; - // 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出 - await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK); + // 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出。 + // 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。 + const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined; + await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref); setAdPortraitId(null); setAdTriId(null); } async function regenTri() {