feat: 角色重跑参考当前立绘+提示词,保持人物一致 (row42)

原「重跑」立绘是纯文生图,每次重抽成另一个随机人。改为:角色已有当前立绘时,
重跑传该立绘作参考图 → 后端走 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 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-30 13:20:47 +08:00
co-authored by Claude Opus 4.8
parent 535e557728
commit 4d2b9343b3
5 changed files with 44 additions and 15 deletions
+32 -6
View File
@@ -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 改
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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) =>
+1 -1
View File
@@ -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 }) {
+7 -5
View File
@@ -468,7 +468,7 @@ export function PipelinePage(props: {
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
onAdoptVideoVersion: (segmentId: string, versionId: 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, referenceAssetId?: 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>;
@@ -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<GenResult> {
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise<GenResult> {
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() {