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() {