feat(core): 基础资产页按流程文档补完 step4(人物/场景详情+演员库+三视图绑定立绘)

人物/场景:
- 卡片可点开详情弹窗:立绘大图(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 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 12:00:56 +08:00
co-authored by Claude Opus 4.8
parent b30eaed838
commit a9b7f077b9
7 changed files with 690 additions and 73 deletions
+52
View File
@@ -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
+31
View File
@@ -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: