feat: 解耦角色与模特并完善模特库
This commit is contained in:
@@ -26,6 +26,7 @@ core/.tools/
|
|||||||
# 本地一键启动脚本,不入库
|
# 本地一键启动脚本,不入库
|
||||||
start-code.bat
|
start-code.bat
|
||||||
stop-code.bat
|
stop-code.bat
|
||||||
|
check-code.bat
|
||||||
|
|
||||||
# bug_todo,不入库,用于记录 bug 修复进度
|
# bug_todo,不入库,用于记录 bug 修复进度
|
||||||
docs/bug_todo/
|
docs/bug_todo/
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ PROVIDER_API_VERSIONS = {
|
|||||||
# 自由创作视频:团队在途任务并发上限(视频长时高价,限并发同时压 web 慢调用敞口)
|
# 自由创作视频:团队在途任务并发上限(视频长时高价,限并发同时压 web 慢调用敞口)
|
||||||
FREE_VIDEO_MAX_CONCURRENT = int(env("FREE_VIDEO_MAX_CONCURRENT", "3"))
|
FREE_VIDEO_MAX_CONCURRENT = int(env("FREE_VIDEO_MAX_CONCURRENT", "3"))
|
||||||
|
|
||||||
|
# 团队模特库三视图生成:必须在 API 与 Celery worker 同步部署新代码后才显式开启。
|
||||||
|
# 9.4A 默认关闭,避免本地 API 把新任务投给共享的旧 worker。
|
||||||
|
MODEL_TRIVIEW_GENERATION_ENABLED = env_bool("MODEL_TRIVIEW_GENERATION_ENABLED", False)
|
||||||
|
|
||||||
# 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有
|
# 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有
|
||||||
ASSETS_API = {
|
ASSETS_API = {
|
||||||
"access_key": env("ASSETS_API_ACCESS_KEY", ""),
|
"access_key": env("ASSETS_API_ACCESS_KEY", ""),
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("ai", "0026_conversation_task_purged_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="aitask",
|
||||||
|
name="task_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("script_generation", "Script Generation"),
|
||||||
|
("script_optimization", "Script Optimization"),
|
||||||
|
("entity_extraction", "Entity Extraction"),
|
||||||
|
("product_image", "Product Image"),
|
||||||
|
("person_image", "Person Image"),
|
||||||
|
("model_triview", "Model Triview"),
|
||||||
|
("scene_image", "Scene Image"),
|
||||||
|
("storyboard", "Storyboard"),
|
||||||
|
("video_segment", "Video Segment"),
|
||||||
|
("voiceover", "Voiceover"),
|
||||||
|
("export", "Export"),
|
||||||
|
("free_video", "Free Video"),
|
||||||
|
],
|
||||||
|
max_length=48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -94,6 +94,7 @@ class AITask(TeamOwnedModel):
|
|||||||
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
||||||
PRODUCT_IMAGE = "product_image", "Product Image"
|
PRODUCT_IMAGE = "product_image", "Product Image"
|
||||||
PERSON_IMAGE = "person_image", "Person Image"
|
PERSON_IMAGE = "person_image", "Person Image"
|
||||||
|
MODEL_TRIVIEW = "model_triview", "Model Triview"
|
||||||
SCENE_IMAGE = "scene_image", "Scene Image"
|
SCENE_IMAGE = "scene_image", "Scene Image"
|
||||||
STORYBOARD = "storyboard", "Storyboard"
|
STORYBOARD = "storyboard", "Storyboard"
|
||||||
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
||||||
|
|||||||
@@ -1319,9 +1319,9 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
"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 "",
|
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
||||||
"use_edit": use_edit, "reference_image": ref_url,
|
"use_edit": use_edit, "reference_image": ref_url,
|
||||||
# 角色立绘出图成功后,worker 内自动接力生成它配套的三视图(ZWQ#3)。仅人物立绘生效;
|
# 角色立绘不再自动接力三视图;三视图只通过角色详情里的显式按钮生成。
|
||||||
# 默认关闭(显式由调用方传 True),避免给商品/场景或不需要三视图的流程白白多扣一次费。
|
# 保留字段为 False,兼容旧前端/旧任务读取,但不允许再触发自动链路。
|
||||||
"auto_triview": bool(auto_triview) and kind == BaseAssetGroup.Kind.PERSON,
|
"auto_triview": False,
|
||||||
}
|
}
|
||||||
task = create_ai_task(
|
task = create_ai_task(
|
||||||
project=project,
|
project=project,
|
||||||
@@ -1426,17 +1426,6 @@ def run_base_asset_task(*, task_id: str) -> None:
|
|||||||
from apps.assets.review import submit_asset_for_review
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||||
# ZWQ#3 · 角色立绘出图成功后自动接力生成它配套的三视图(opt-in;商品/场景不触发)。
|
|
||||||
# 放 on_commit:确保立绘已落库再据它发起三视图(image_edit 以该立绘为参考)。
|
|
||||||
# best-effort:三视图发起失败(额度不足/模型不支持 image_edit 等)不影响立绘本身已成功。
|
|
||||||
if kind == BaseAssetGroup.Kind.PERSON and payload.get("auto_triview"):
|
|
||||||
def _kickoff_triview(portrait=asset, proj=project, usr=user):
|
|
||||||
try:
|
|
||||||
generate_person_triview(project=proj, user=usr, portrait_asset=portrait)
|
|
||||||
except Exception: # noqa: BLE001 — 三视图接力是附加能力,失败绝不回头弄挂立绘流程
|
|
||||||
logger.exception("auto triview kickoff failed for portrait %s", getattr(portrait, "id", "?"))
|
|
||||||
|
|
||||||
transaction.on_commit(_kickoff_triview)
|
|
||||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
task.status = AITask.Status.FAILED
|
task.status = AITask.Status.FAILED
|
||||||
task.error_message = str(exc)
|
task.error_message = str(exc)
|
||||||
@@ -1474,6 +1463,202 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
_MODEL_TRIVIEW_INFLIGHT = (
|
||||||
|
AITask.Status.CREATED,
|
||||||
|
AITask.Status.RESERVED,
|
||||||
|
AITask.Status.SUBMITTED,
|
||||||
|
AITask.Status.POLLING,
|
||||||
|
AITask.Status.POSTPROCESSING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_model_triview(*, model):
|
||||||
|
"""模特三视图价格:图像模型标准价(当前 20 积分)× 团队价格系数。"""
|
||||||
|
from apps.billing.pricing import quote_flat
|
||||||
|
|
||||||
|
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||||
|
if model_config is None:
|
||||||
|
raise ValueError("no active image model configured")
|
||||||
|
return model_config, quote_flat(model_config, team=model.team)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_model_triview(*, model, user) -> tuple[AITask, bool]:
|
||||||
|
"""为团队级 Model 提交三视图任务;project 保持空,同一模特只允许一个在途任务。"""
|
||||||
|
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||||||
|
from apps.ai.tasks import generate_model_triview_task
|
||||||
|
from apps.assets.models import Model
|
||||||
|
|
||||||
|
if model.is_official:
|
||||||
|
raise ValueError("官方模特不可生成三视图")
|
||||||
|
if model.portrait_asset_id is None:
|
||||||
|
raise ValueError("请先设置模特形象图")
|
||||||
|
model_config, quote = quote_model_triview(model=model)
|
||||||
|
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(model.portrait_asset)
|
||||||
|
if not ref_url:
|
||||||
|
raise ValueError("模特形象图不可用,请先重新上传")
|
||||||
|
prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
locked = Model.objects.select_for_update().select_related("portrait_asset", "team").get(id=model.id)
|
||||||
|
existing = (
|
||||||
|
AITask.objects.filter(
|
||||||
|
team=locked.team,
|
||||||
|
task_type=AITask.Type.MODEL_TRIVIEW,
|
||||||
|
status__in=_MODEL_TRIVIEW_INFLIGHT,
|
||||||
|
request_payload__model_id=str(locked.id),
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
payload = {
|
||||||
|
"model": model_config.name,
|
||||||
|
"prompt": prompt,
|
||||||
|
"kind": "model_triview",
|
||||||
|
"model_id": str(locked.id),
|
||||||
|
"portrait_asset_id": str(locked.portrait_asset_id),
|
||||||
|
"reference_image": ref_url,
|
||||||
|
"price_points": str(quote.points),
|
||||||
|
}
|
||||||
|
task = AITask.objects.create(
|
||||||
|
team=locked.team,
|
||||||
|
created_by=user,
|
||||||
|
project=None,
|
||||||
|
task_type=AITask.Type.MODEL_TRIVIEW,
|
||||||
|
status=AITask.Status.CREATED,
|
||||||
|
model_config=model_config,
|
||||||
|
idempotency_key=f"model_triview:{locked.id}:{uuid.uuid4()}",
|
||||||
|
request_payload=payload,
|
||||||
|
estimated_cost=quote.points,
|
||||||
|
base_cost=quote.base_cost_yuan,
|
||||||
|
)
|
||||||
|
reserve_credit(team=locked.team, user=user, task=task, amount=quote.points)
|
||||||
|
task.status = AITask.Status.RESERVED
|
||||||
|
task.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
generate_model_triview_task.delay(str(task.id))
|
||||||
|
return task, True
|
||||||
|
|
||||||
|
|
||||||
|
def run_model_triview_task(*, task_id: str) -> None:
|
||||||
|
"""worker 执行团队模特三视图:成功扣费并切换 Model.triview_asset;失败释放预留。"""
|
||||||
|
from apps.assets.models import Model
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
task = (
|
||||||
|
AITask.objects.select_for_update()
|
||||||
|
.select_related("team", "created_by", "model_config")
|
||||||
|
.filter(id=task_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if task is None or task.status != AITask.Status.RESERVED:
|
||||||
|
return
|
||||||
|
task.status = AITask.Status.SUBMITTED
|
||||||
|
task.submitted_at = timezone.now()
|
||||||
|
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||||||
|
|
||||||
|
payload = task.request_payload or {}
|
||||||
|
model_id = str(payload.get("model_id") or "")
|
||||||
|
portrait_asset_id = str(payload.get("portrait_asset_id") or "")
|
||||||
|
ref_url = str(payload.get("reference_image") or "")
|
||||||
|
prompt = str(payload.get("prompt") or "")
|
||||||
|
provider = get_image_provider(task.model_config)
|
||||||
|
reservation = task.credit_reservation
|
||||||
|
try:
|
||||||
|
if not model_id or not portrait_asset_id or not ref_url:
|
||||||
|
raise ValueError("模特三视图任务参数不完整")
|
||||||
|
|
||||||
|
def _make():
|
||||||
|
size = prompt_ratio_size("person_triview", "1536x1024")
|
||||||
|
response = provider.image_edit(
|
||||||
|
model=task.model_config.name,
|
||||||
|
prompt=prompt,
|
||||||
|
images=[ref_url],
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
return response, provider.extract_first_media_url(response)
|
||||||
|
|
||||||
|
response, media = _run_image_with_retry(_make)
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_model = (
|
||||||
|
Model.objects.select_for_update()
|
||||||
|
.filter(id=model_id, team=task.team, is_deleted=False, purged_at__isnull=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if locked_model is None:
|
||||||
|
raise ValueError("模特不存在或已删除")
|
||||||
|
if str(locked_model.portrait_asset_id or "") != portrait_asset_id:
|
||||||
|
raise ValueError("模特形象图已变化,请重新生成三视图")
|
||||||
|
|
||||||
|
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||||
|
suffix = ".jpg" if "jpeg" in content_type else ".webp" if "webp" in content_type else ".png"
|
||||||
|
asset_id = uuid.uuid4()
|
||||||
|
stored = TosStorage().upload_fileobj(
|
||||||
|
fileobj=fileobj,
|
||||||
|
object_key=f"teams/{task.team_id}/models/{locked_model.id}/triviews/{asset_id}{suffix}",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
triview = Asset.objects.create(
|
||||||
|
id=asset_id,
|
||||||
|
team=task.team,
|
||||||
|
created_by=task.created_by,
|
||||||
|
name=f"{locked_model.name}·三视图",
|
||||||
|
asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.AI_GENERATED,
|
||||||
|
category=Asset.Category.TRI_VIEW,
|
||||||
|
in_library=False,
|
||||||
|
origin_task=task,
|
||||||
|
metadata={
|
||||||
|
"kind": "model",
|
||||||
|
"view": "three_view",
|
||||||
|
"model_id": str(locked_model.id),
|
||||||
|
"triview_of": portrait_asset_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=triview,
|
||||||
|
object_key=stored.object_key,
|
||||||
|
bucket=stored.bucket,
|
||||||
|
content_type=stored.content_type,
|
||||||
|
size_bytes=stored.size_bytes,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
versions = [str(value) for value in (locked_model.metadata or {}).get("triview_versions", []) if value]
|
||||||
|
for value in (locked_model.triview_asset_id, triview.id):
|
||||||
|
if value and str(value) not in versions:
|
||||||
|
versions.append(str(value))
|
||||||
|
metadata = dict(locked_model.metadata or {})
|
||||||
|
metadata["triview_versions"] = versions
|
||||||
|
locked_model.triview_asset = triview
|
||||||
|
locked_model.metadata = metadata
|
||||||
|
locked_model.save(update_fields=["triview_asset", "metadata", "updated_at"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
|
transaction.on_commit(lambda asset=triview: submit_asset_for_review(asset))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_task = AITask.objects.select_for_update().get(id=task.id)
|
||||||
|
if locked_task.status == AITask.Status.SUCCEEDED:
|
||||||
|
return
|
||||||
|
locked_task.status = AITask.Status.FAILED
|
||||||
|
locked_task.error_message = str(exc)
|
||||||
|
locked_task.completed_at = timezone.now()
|
||||||
|
locked_task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
|
||||||
|
|
||||||
def run_triview_task(*, task_id: str) -> None:
|
def run_triview_task(*, task_id: str) -> None:
|
||||||
"""Celery worker 内执行三视图慢出图(image_edit 以立绘为参考):成功落库扣费并归到立绘的三视图组 / 失败退费。
|
"""Celery worker 内执行三视图慢出图(image_edit 以立绘为参考):成功落库扣费并归到立绘的三视图组 / 失败退费。
|
||||||
幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。"""
|
幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。"""
|
||||||
@@ -1521,26 +1706,6 @@ def run_triview_task(*, task_id: str) -> None:
|
|||||||
group.candidate_assets.add(asset)
|
group.candidate_assets.add(asset)
|
||||||
group.adopted_asset = asset
|
group.adopted_asset = asset
|
||||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||||
# B 路(期3):视频流程新生成的「角色」立绘 + 三视图成套 → 自动加入/更新模特库(团队级可复用)。
|
|
||||||
# 幂等:同一立绘只建一次 Model;已存在该立绘的 Model(尤其「真人上传」模特,upload 时只建了 portrait、
|
|
||||||
# 还没 triview)→ 回写 triview_asset,否则模特库永远显示「无三视图」(ZWQ#7:AI 角色能更新、真人上传不更新)。
|
|
||||||
from apps.assets.models import Model as ModelEntity
|
|
||||||
|
|
||||||
portrait = Asset.objects.filter(id=asset_key).first()
|
|
||||||
if portrait is not None:
|
|
||||||
existing_model = ModelEntity.objects.filter(portrait_asset=portrait, is_deleted=False).first()
|
|
||||||
if existing_model is None:
|
|
||||||
ModelEntity.objects.create(
|
|
||||||
team=project.team, created_by=user,
|
|
||||||
name=(portrait.name or f"{project.name}-角色").split("·")[0].strip()[:255] or "角色",
|
|
||||||
source=ModelEntity.Source.AI,
|
|
||||||
portrait_asset=portrait, triview_asset=asset,
|
|
||||||
metadata={"from_project": str(project.id), "auto_enrolled": True},
|
|
||||||
)
|
|
||||||
elif existing_model.triview_asset_id != asset.id:
|
|
||||||
# 真人上传 / 已有模特补三视图:回写新三视图(应用后模特库即刷新三视图)
|
|
||||||
existing_model.triview_asset = asset
|
|
||||||
existing_model.save(update_fields=["triview_asset", "updated_at"])
|
|
||||||
# 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
|
# 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort)
|
||||||
from apps.assets.review import submit_asset_for_review
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
@@ -2440,11 +2605,11 @@ def create_export_job(*, timeline, user) -> ExportJob:
|
|||||||
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||||||
|
|
||||||
|
|
||||||
# 图片趴三类(模特库+资产模型重构 期2):
|
# 图片趴三类(模特库+资产模型重构):
|
||||||
# · model + product → model_tryon(模特上身图);model 无 product → person(视频角色「生成演员」,留期3)
|
# · model + product → model_tryon(模特上身图);model 无 product → model_portrait(新建模特候选)
|
||||||
# · cover → platform_kit(平台套图);image → free_create(自由创作)
|
# · cover → platform_kit(平台套图);image → free_create(自由创作)
|
||||||
_STANDALONE_CATEGORY = {
|
_STANDALONE_CATEGORY = {
|
||||||
"model": Asset.Category.PERSON,
|
"model": Asset.Category.MODEL_PORTRAIT,
|
||||||
"cover": Asset.Category.PLATFORM_KIT,
|
"cover": Asset.Category.PLATFORM_KIT,
|
||||||
"image": Asset.Category.FREE_CREATE,
|
"image": Asset.Category.FREE_CREATE,
|
||||||
}
|
}
|
||||||
@@ -2573,7 +2738,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
index = int(payload.get("index") or 0)
|
index = int(payload.get("index") or 0)
|
||||||
product_id = payload.get("product_id") or None
|
product_id = payload.get("product_id") or None
|
||||||
# 模特上身图(mode=model 且绑了商品)= 图片趴「模特上身图」(引用模特库),归 model_tryon、不送审;
|
# 模特上身图(mode=model 且绑了商品)= 图片趴「模特上身图」(引用模特库),归 model_tryon、不送审;
|
||||||
# 「生成演员」同样走 mode=model 但无 product_id = 视频角色,仍归 person(送审,留期3 收编为「角色」)。
|
# 无商品的 mode=model 仅用于新建模特候选,归 model_portrait;项目角色只由项目基础资产流程创建 person。
|
||||||
if mode == "model" and product_id:
|
if mode == "model" and product_id:
|
||||||
category = Asset.Category.MODEL_TRYON
|
category = Asset.Category.MODEL_TRYON
|
||||||
else:
|
else:
|
||||||
@@ -2695,11 +2860,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
||||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
|
||||||
metadata=asset_meta,
|
metadata=asset_meta,
|
||||||
# R109:图片生成(模特上身图/平台套图/图片创作/商品三视图)的成图**自动入资产库**,
|
# 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library;
|
||||||
# 不再由用户点「加入资产库」手动入库。仅「生成演员」(mode=model 无商品 → person)
|
# 商品/创作等图片趴成图保持原有自动入库行为。
|
||||||
# 仍暂存(in_library=False),由演员库「保存人物」时入库——否则未保存的立绘会
|
in_library=asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT),
|
||||||
# 混进「我的演员」列表(ZWQ#6)。
|
|
||||||
in_library=asset_category != Asset.Category.PERSON,
|
|
||||||
)
|
)
|
||||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ def generate_triview_task(self, task_id: str) -> str:
|
|||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True, max_retries=0)
|
||||||
|
def generate_model_triview_task(self, task_id: str) -> str:
|
||||||
|
"""团队模特库三视图:不绑定项目,成功更新 Model.triview_asset,失败释放预留积分。"""
|
||||||
|
from apps.ai.services import run_model_triview_task
|
||||||
|
|
||||||
|
run_model_triview_task(task_id=task_id)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True, max_retries=0)
|
@app.task(bind=True, max_retries=0)
|
||||||
def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||||
"""自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat),
|
"""自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat),
|
||||||
|
|||||||
+213
-20
@@ -182,7 +182,7 @@ from apps.ai.models import AITask, ImageConversation, ModelConfig, ModelProvider
|
|||||||
from apps.ai.providers.volcano import VolcanoArkProvider
|
from apps.ai.providers.volcano import VolcanoArkProvider
|
||||||
from apps.ai.services import enqueue_standalone_images
|
from apps.ai.services import enqueue_standalone_images
|
||||||
from apps.assets.models import Asset, AssetFile
|
from apps.assets.models import Asset, AssetFile
|
||||||
from apps.billing.models import CreditAccount
|
from apps.billing.models import CreditAccount, CreditLedger
|
||||||
from apps.products.models import Product
|
from apps.products.models import Product
|
||||||
|
|
||||||
|
|
||||||
@@ -742,17 +742,15 @@ class StandaloneCategoryTests(TestCase):
|
|||||||
self.assertEqual(a.category, Asset.Category.FREE_CREATE)
|
self.assertEqual(a.category, Asset.Category.FREE_CREATE)
|
||||||
self.assertTrue(a.in_library) # R109
|
self.assertTrue(a.in_library) # R109
|
||||||
|
|
||||||
def test_generate_actor_stays_person(self):
|
def test_generate_model_candidate_is_model_portrait_and_not_in_asset_library(self):
|
||||||
# 生成演员:mode=model 但无 product → 视频角色,仍 person(送审范围内)
|
# 图片生成 mode=model 且无商品 = 新建模特候选,不再生成“演员”person 资产。
|
||||||
a = self._run("model")
|
a = self._run("model")
|
||||||
self.assertEqual(a.category, Asset.Category.PERSON)
|
self.assertEqual(a.category, Asset.Category.MODEL_PORTRAIT)
|
||||||
# R109 例外:演员立绘仍暂存(不自动入库),由演员库「保存人物」时置 True——
|
|
||||||
# 否则未保存立绘会混进「我的演员」列表(ZWQ#6)
|
|
||||||
self.assertFalse(a.in_library)
|
self.assertFalse(a.in_library)
|
||||||
|
|
||||||
|
|
||||||
class TriviewAutoEnrollTests(TestCase):
|
class TriviewModelDecouplingTests(TestCase):
|
||||||
"""B 路(期3):视频流程新生成的角色立绘 + 三视图成套 → 自动入模特库;幂等不重复建。"""
|
"""项目角色三视图只归项目,不自动创建或更新团队模特。"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -771,6 +769,7 @@ class TriviewAutoEnrollTests(TestCase):
|
|||||||
def _patch_provider(self):
|
def _patch_provider(self):
|
||||||
provider = patch("apps.ai.services.get_image_provider").start()
|
provider = patch("apps.ai.services.get_image_provider").start()
|
||||||
prov = provider.return_value
|
prov = provider.return_value
|
||||||
|
prov.image_generation.return_value = {"data": [{"url": "http://x/person.png"}]}
|
||||||
prov.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
prov.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
||||||
prov.extract_first_media_url.return_value = "http://x/tri.png"
|
prov.extract_first_media_url.return_value = "http://x/tri.png"
|
||||||
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
|
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
|
||||||
@@ -786,27 +785,221 @@ class TriviewAutoEnrollTests(TestCase):
|
|||||||
task = generate_person_triview(project=self.project, user=self.user, portrait_asset=self.portrait)
|
task = generate_person_triview(project=self.project, user=self.user, portrait_asset=self.portrait)
|
||||||
run_triview_task(task_id=str(task.id))
|
run_triview_task(task_id=str(task.id))
|
||||||
|
|
||||||
def test_triview_auto_enrolls_model(self):
|
def test_triview_stays_on_project_and_does_not_enroll_model(self):
|
||||||
from apps.assets.models import Model
|
from apps.assets.models import Model
|
||||||
|
|
||||||
self._patch_provider()
|
self._patch_provider()
|
||||||
self._gen_triview()
|
self._gen_triview()
|
||||||
m = Model.objects.filter(portrait_asset=self.portrait).first()
|
self.assertFalse(Model.objects.filter(portrait_asset=self.portrait).exists())
|
||||||
self.assertIsNotNone(m)
|
group = next(g for g in self.project.base_asset_groups.all() if (g.metadata or {}).get("triview_of") == str(self.portrait.id))
|
||||||
self.assertIsNotNone(m.triview_asset) # 成套
|
self.assertIsNotNone(group.adopted_asset)
|
||||||
self.assertTrue(m.metadata.get("auto_enrolled"))
|
self.assertEqual(group.adopted_asset.category, Asset.Category.TRI_VIEW)
|
||||||
self.assertEqual(m.source, Model.Source.AI)
|
self.assertIn(group.adopted_asset.category, Asset.REVIEW_CATEGORIES)
|
||||||
# 合规(期3):三视图归 tri_view → 落在送审范围内
|
|
||||||
self.assertEqual(m.triview_asset.category, Asset.Category.TRI_VIEW)
|
|
||||||
self.assertIn(m.triview_asset.category, Asset.REVIEW_CATEGORIES)
|
|
||||||
|
|
||||||
def test_auto_enroll_is_idempotent(self):
|
def test_triview_does_not_overwrite_existing_model_triview(self):
|
||||||
from apps.assets.models import Model
|
from apps.assets.models import Model
|
||||||
|
|
||||||
|
existing_tri = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="模特原三视图", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.AI_GENERATED, category=Asset.Category.TRI_VIEW,
|
||||||
|
)
|
||||||
|
model = Model.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="已有模特", portrait_asset=self.portrait, triview_asset=existing_tri,
|
||||||
|
)
|
||||||
self._patch_provider()
|
self._patch_provider()
|
||||||
self._gen_triview()
|
self._gen_triview()
|
||||||
self._gen_triview() # 同一立绘再出一版三视图
|
model.refresh_from_db()
|
||||||
self.assertEqual(Model.objects.filter(portrait_asset=self.portrait).count(), 1)
|
self.assertEqual(model.triview_asset_id, existing_tri.id)
|
||||||
|
|
||||||
|
@patch("apps.ai.services.generate_person_triview")
|
||||||
|
def test_person_base_asset_ignores_legacy_auto_triview_flag(self, kickoff_triview):
|
||||||
|
"""即使旧前端/旧任务传 auto_triview=True,角色立绘完成后也不能自动接力三视图。"""
|
||||||
|
from apps.ai.services import generate_base_asset
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
ModelConfig.objects.create(
|
||||||
|
provider=ModelProvider.objects.create(name="auto-tri-provider", display_name="Auto Tri Provider"),
|
||||||
|
name="auto-tri-img",
|
||||||
|
display_name="Auto Tri Img",
|
||||||
|
capability=ModelConfig.Capability.IMAGE,
|
||||||
|
endpoint="images/generations",
|
||||||
|
unit_price="1.0000",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
self._patch_provider()
|
||||||
|
|
||||||
|
task = generate_base_asset(
|
||||||
|
project=self.project,
|
||||||
|
user=self.user,
|
||||||
|
kind=BaseAssetGroup.Kind.PERSON,
|
||||||
|
prompt="项目角色立绘",
|
||||||
|
label="女主",
|
||||||
|
auto_triview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertFalse(task.request_payload.get("auto_triview"))
|
||||||
|
kickoff_triview.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelLibraryTriviewTaskTests(TestCase):
|
||||||
|
"""团队模特三视图:无项目归属、20 积分闭环、幂等防重复、只更新当前 Model。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.assets.models import Model
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(username="model-tri", password="p")
|
||||||
|
self.team = Team.objects.create(name="Model Tri", owner=self.user)
|
||||||
|
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
self.account = CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||||
|
provider = ModelProvider.objects.create(
|
||||||
|
name="model-tri-provider",
|
||||||
|
display_name="Model Tri Provider",
|
||||||
|
base_url="http://x.test/v1",
|
||||||
|
api_key="test-key",
|
||||||
|
)
|
||||||
|
self.config = ModelConfig.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
name="model-tri-image",
|
||||||
|
display_name="Model Tri Image",
|
||||||
|
capability=ModelConfig.Capability.IMAGE,
|
||||||
|
endpoint="images/edits",
|
||||||
|
unit_price="20.0000",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
self.portrait = Asset.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
created_by=self.user,
|
||||||
|
name="模特形象",
|
||||||
|
asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.UPLOAD,
|
||||||
|
category=Asset.Category.MODEL_PORTRAIT,
|
||||||
|
in_library=False,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=self.portrait,
|
||||||
|
object_key="portrait.png",
|
||||||
|
bucket="b",
|
||||||
|
content_type="image/png",
|
||||||
|
preview_url="http://x/portrait.png",
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.model = Model.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
created_by=self.user,
|
||||||
|
name="团队模特",
|
||||||
|
portrait_asset=self.portrait,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _submit(self):
|
||||||
|
from apps.ai.services import generate_model_triview
|
||||||
|
|
||||||
|
with patch("apps.ai.tasks.generate_model_triview_task.delay") as delay:
|
||||||
|
result = generate_model_triview(model=self.model, user=self.user)
|
||||||
|
return result, delay
|
||||||
|
|
||||||
|
def _provider_success(self):
|
||||||
|
provider = patch("apps.ai.services.get_image_provider").start().return_value
|
||||||
|
provider.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
||||||
|
provider.extract_first_media_url.return_value = "http://x/tri.png"
|
||||||
|
patch("apps.ai.services.VolcanoArkProvider.media_to_bytes", return_value=(BytesIO(b"img"), "image/png")).start()
|
||||||
|
storage = patch("apps.ai.services.TosStorage").start()
|
||||||
|
stored = storage.return_value.upload_fileobj.return_value
|
||||||
|
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "tri.png", "b", "image/png", 3
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
def test_submit_is_projectless_reserves_20_and_reuses_inflight(self):
|
||||||
|
(task, created), delay = self._submit()
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertIsNone(task.project_id)
|
||||||
|
self.assertEqual(task.task_type, AITask.Type.MODEL_TRIVIEW)
|
||||||
|
self.assertEqual(float(task.estimated_cost), 20.0)
|
||||||
|
self.assertEqual(task.request_payload["model_id"], str(self.model.id))
|
||||||
|
delay.assert_called_once_with(str(task.id))
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
self.assertEqual(float(self.account.reserved_balance), 20.0)
|
||||||
|
reserve = CreditLedger.objects.get(task=task, ledger_type=CreditLedger.Type.RESERVE)
|
||||||
|
self.assertEqual(reserve.reason, "AI 任务预扣额度")
|
||||||
|
|
||||||
|
(same_task, created_again), second_delay = self._submit()
|
||||||
|
self.assertFalse(created_again)
|
||||||
|
self.assertEqual(same_task.id, task.id)
|
||||||
|
second_delay.assert_not_called()
|
||||||
|
self.assertEqual(AITask.objects.filter(task_type=AITask.Type.MODEL_TRIVIEW).count(), 1)
|
||||||
|
|
||||||
|
def test_success_charges_once_and_updates_only_model_triview(self):
|
||||||
|
from apps.ai.services import run_model_triview_task
|
||||||
|
|
||||||
|
(task, _created), _delay = self._submit()
|
||||||
|
self._provider_success()
|
||||||
|
run_model_triview_task(task_id=str(task.id))
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.model.refresh_from_db()
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||||
|
self.assertEqual(float(task.actual_cost), 20.0)
|
||||||
|
self.assertEqual(float(self.account.balance), 80.0)
|
||||||
|
self.assertEqual(float(self.account.reserved_balance), 0.0)
|
||||||
|
self.assertIsNotNone(self.model.triview_asset_id)
|
||||||
|
self.assertEqual(self.model.triview_asset.category, Asset.Category.TRI_VIEW)
|
||||||
|
self.assertFalse(self.model.triview_asset.in_library)
|
||||||
|
self.assertEqual(self.model.triview_asset.metadata["model_id"], str(self.model.id))
|
||||||
|
self.assertEqual(self.model.metadata["triview_versions"], [str(self.model.triview_asset_id)])
|
||||||
|
charge = CreditLedger.objects.get(task=task, ledger_type=CreditLedger.Type.CHARGE)
|
||||||
|
self.assertEqual(charge.reason, "AI 任务扣费")
|
||||||
|
|
||||||
|
run_model_triview_task(task_id=str(task.id))
|
||||||
|
self.assertEqual(CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
|
||||||
|
|
||||||
|
def test_failure_releases_credit_and_keeps_old_triview(self):
|
||||||
|
from apps.ai.services import run_model_triview_task
|
||||||
|
|
||||||
|
old_tri = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="旧三视图", asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.AI_GENERATED, category=Asset.Category.TRI_VIEW, in_library=False,
|
||||||
|
)
|
||||||
|
self.model.triview_asset = old_tri
|
||||||
|
self.model.save(update_fields=["triview_asset"])
|
||||||
|
(task, _created), _delay = self._submit()
|
||||||
|
provider = patch("apps.ai.services.get_image_provider").start().return_value
|
||||||
|
provider.image_edit.side_effect = ValueError("生成失败")
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
|
||||||
|
run_model_triview_task(task_id=str(task.id))
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.model.refresh_from_db()
|
||||||
|
self.account.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||||
|
self.assertEqual(self.model.triview_asset_id, old_tri.id)
|
||||||
|
self.assertEqual(float(self.account.balance), 100.0)
|
||||||
|
self.assertEqual(float(self.account.reserved_balance), 0.0)
|
||||||
|
self.assertFalse(CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.CHARGE).exists())
|
||||||
|
self.assertTrue(CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.RELEASE).exists())
|
||||||
|
|
||||||
|
def test_api_quote_submit_and_status_contract(self):
|
||||||
|
quote = self.client.get(f"/api/models/{self.model.id}/triview-quote/")
|
||||||
|
self.assertEqual(quote.status_code, 200)
|
||||||
|
self.assertEqual(float(quote.json()["points"]), 20.0)
|
||||||
|
|
||||||
|
disabled = self.client.post(f"/api/models/{self.model.id}/generate-triview/")
|
||||||
|
self.assertEqual(disabled.status_code, 409)
|
||||||
|
self.assertFalse(AITask.objects.filter(task_type=AITask.Type.MODEL_TRIVIEW).exists())
|
||||||
|
|
||||||
|
with self.settings(MODEL_TRIVIEW_GENERATION_ENABLED=True):
|
||||||
|
with patch("apps.ai.tasks.generate_model_triview_task.delay"):
|
||||||
|
submit = self.client.post(f"/api/models/{self.model.id}/generate-triview/")
|
||||||
|
self.assertEqual(submit.status_code, 202)
|
||||||
|
task_id = submit.json()["task_id"]
|
||||||
|
self.assertEqual(float(submit.json()["estimated_cost"]), 20.0)
|
||||||
|
|
||||||
|
status_res = self.client.get(f"/api/models/{self.model.id}/triview-status/?task_id={task_id}")
|
||||||
|
self.assertEqual(status_res.status_code, 200)
|
||||||
|
self.assertEqual(status_res.json()["status"], AITask.Status.RESERVED)
|
||||||
|
self.assertIsNone(status_res.json()["model"])
|
||||||
|
|
||||||
|
|
||||||
class _FakeStreamResp:
|
class _FakeStreamResp:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""审计“我的演员”收口为模特前的历史数据;只读,不写库。"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.assets.models import Asset, Model
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "只读审计旧 person 资产,输出可安全迁移为 Model 的候选和排除原因。"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--team-id", help="仅审计指定团队 UUID")
|
||||||
|
parser.add_argument("--limit", type=int, default=50, help="最多打印多少条候选(默认 50)")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
team_id = options.get("team_id")
|
||||||
|
limit = max(0, options["limit"])
|
||||||
|
|
||||||
|
active_people = Asset.objects.filter(
|
||||||
|
category=Asset.Category.PERSON,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
)
|
||||||
|
if team_id:
|
||||||
|
active_people = active_people.filter(team_id=team_id)
|
||||||
|
|
||||||
|
# 项目基础资产组引用的 person(包含采用版和候选版)一律视为项目角色,不自动迁移。
|
||||||
|
project_role_asset_ids = set(
|
||||||
|
Asset.objects.filter(
|
||||||
|
Q(adopted_base_groups__kind=BaseAssetGroup.Kind.PERSON)
|
||||||
|
| Q(candidate_base_groups__kind=BaseAssetGroup.Kind.PERSON)
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
if team_id:
|
||||||
|
project_role_asset_ids &= set(active_people.values_list("id", flat=True))
|
||||||
|
|
||||||
|
existing_model_portraits = set(
|
||||||
|
Model.objects.filter(
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
portrait_asset__isnull=False,
|
||||||
|
)
|
||||||
|
.values_list("portrait_asset_id", flat=True)
|
||||||
|
)
|
||||||
|
if team_id:
|
||||||
|
existing_model_portraits &= set(active_people.values_list("id", flat=True))
|
||||||
|
|
||||||
|
saved_people = active_people.filter(in_library=True)
|
||||||
|
project_generated_ids = set(
|
||||||
|
active_people.filter(origin_task__project__isnull=False).values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
eligible = (
|
||||||
|
saved_people.exclude(id__in=project_role_asset_ids)
|
||||||
|
.exclude(id__in=project_generated_ids)
|
||||||
|
.exclude(id__in=existing_model_portraits)
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
trash_people = Asset.objects.filter(
|
||||||
|
category=Asset.Category.PERSON,
|
||||||
|
is_deleted=True,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
)
|
||||||
|
purged_people = Asset.objects.filter(category=Asset.Category.PERSON, purged_at__isnull=False)
|
||||||
|
if team_id:
|
||||||
|
trash_people = trash_people.filter(team_id=team_id)
|
||||||
|
purged_people = purged_people.filter(team_id=team_id)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.WARNING("=== audit_actor_model_migration · DRY-RUN / READ-ONLY ==="))
|
||||||
|
self.stdout.write(f"正常 person 资产: {active_people.count()}")
|
||||||
|
self.stdout.write(f" 已有 Model 形象图: {len(existing_model_portraits)}")
|
||||||
|
self.stdout.write(f" 项目角色引用: {len(project_role_asset_ids)}")
|
||||||
|
self.stdout.write(f" 项目生成角色: {len(project_generated_ids)}")
|
||||||
|
self.stdout.write(f" 旧已保存人物候选: {eligible.count()}")
|
||||||
|
self.stdout.write(f"垃圾桶 person(不迁移): {trash_people.count()}")
|
||||||
|
self.stdout.write(f"彻底隐藏 person(不迁移): {purged_people.count()}")
|
||||||
|
self.stdout.write("\n可迁移候选(只打印,不创建 Model):")
|
||||||
|
for asset in eligible[:limit]:
|
||||||
|
self.stdout.write(f" {asset.id} | team={asset.team_id} | {asset.name}")
|
||||||
|
if eligible.count() > limit:
|
||||||
|
self.stdout.write(f" … 其余 {eligible.count() - limit} 条未打印(可用 --limit 调整)")
|
||||||
|
self.stdout.write(self.style.WARNING("审计结束:未创建、更新、删除任何记录或文件。"))
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""把符合规则的历史“已保存演员”补为 Model;默认 dry-run。"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.assets.models import Asset, Model
|
||||||
|
from apps.projects.models import BaseAssetGroup
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_legacy_actor_assets(team_id=None):
|
||||||
|
"""只返回正常状态、已保存、非项目角色且尚未拥有 Model 的旧 person 资产。"""
|
||||||
|
people = Asset.objects.filter(
|
||||||
|
category=Asset.Category.PERSON,
|
||||||
|
in_library=True,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
)
|
||||||
|
if team_id:
|
||||||
|
people = people.filter(team_id=team_id)
|
||||||
|
|
||||||
|
project_role_ids = Asset.objects.filter(
|
||||||
|
Q(adopted_base_groups__kind=BaseAssetGroup.Kind.PERSON)
|
||||||
|
| Q(candidate_base_groups__kind=BaseAssetGroup.Kind.PERSON)
|
||||||
|
).values_list("id", flat=True)
|
||||||
|
existing_model_ids = Model.objects.filter(
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
portrait_asset__isnull=False,
|
||||||
|
).values_list("portrait_asset_id", flat=True)
|
||||||
|
return (
|
||||||
|
people.exclude(origin_task__project__isnull=False)
|
||||||
|
.exclude(id__in=project_role_ids)
|
||||||
|
.exclude(id__in=existing_model_ids)
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "迁移正常状态的历史已保存演员为 Model;默认 dry-run,--apply 才写库。"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--team-id", help="仅迁移指定团队 UUID")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="真正创建 Model(默认只打印)")
|
||||||
|
parser.add_argument("--limit", type=int, default=50, help="最多打印多少条候选(默认 50)")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
apply = options["apply"]
|
||||||
|
candidates = eligible_legacy_actor_assets(options.get("team_id"))
|
||||||
|
count = candidates.count()
|
||||||
|
limit = max(0, options["limit"])
|
||||||
|
self.stdout.write(self.style.WARNING(f"=== migrate_saved_actors_to_models · {'APPLY' if apply else 'DRY-RUN'} ==="))
|
||||||
|
self.stdout.write(f"可迁移旧保存人物: {count}")
|
||||||
|
for asset in candidates[:limit]:
|
||||||
|
self.stdout.write(f" {asset.id} | team={asset.team_id} | {asset.name}")
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING("DRY-RUN 结束,未写库。加 --apply 才会创建 Model。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for asset in candidates.select_for_update():
|
||||||
|
# select_for_update 后再次确认,保证并发/重复执行都不会造成重复 Model。
|
||||||
|
if Model.objects.filter(
|
||||||
|
portrait_asset=asset,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
).exists():
|
||||||
|
continue
|
||||||
|
Model.objects.create(
|
||||||
|
team_id=asset.team_id,
|
||||||
|
created_by_id=asset.created_by_id,
|
||||||
|
name=(asset.name or "模特")[:255],
|
||||||
|
source=Model.Source.UPLOAD if asset.source == Asset.Source.UPLOAD else Model.Source.AI,
|
||||||
|
portrait_asset=asset,
|
||||||
|
metadata={"migrated_from_legacy_actor": True},
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"完成:创建 {created} 个 Model;未修改任何 Asset、项目、垃圾桶或文件。"))
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO, StringIO
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import SimpleTestCase, TestCase
|
from django.test import SimpleTestCase, TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -44,10 +45,60 @@ class ModelLibraryApiTests(TestCase):
|
|||||||
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
|
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
|
||||||
self.assertEqual(mine, {str(self.mine.id)})
|
self.assertEqual(mine, {str(self.mine.id)})
|
||||||
|
|
||||||
|
def test_filter_by_portrait_asset(self):
|
||||||
|
portrait = Asset.objects.create(team=self.teamA, name="角色立绘", asset_type="image", source="ai_generated")
|
||||||
|
self.mine.portrait_asset = portrait
|
||||||
|
self.mine.save(update_fields=["portrait_asset"])
|
||||||
|
|
||||||
|
rows = self.client.get(f"/api/models/?portrait_asset={portrait.id}").json()["results"]
|
||||||
|
self.assertEqual([row["id"] for row in rows], [str(self.mine.id)])
|
||||||
|
|
||||||
|
missing = self.client.get("/api/models/?portrait_asset=00000000-0000-0000-0000-000000000000").json()["results"]
|
||||||
|
self.assertEqual(missing, [])
|
||||||
|
|
||||||
|
def test_enroll_asset_creates_model_once_and_reuses_it(self):
|
||||||
|
portrait = Asset.objects.create(
|
||||||
|
team=self.teamA, name="AI 模特候选", asset_type="image", source="ai_generated", category=Asset.Category.PERSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "通勤模特"}, format="json")
|
||||||
|
self.assertEqual(first.status_code, 201)
|
||||||
|
self.assertEqual(first.json()["name"], "通勤模特")
|
||||||
|
self.assertEqual(first.json()["portrait_asset"], str(portrait.id))
|
||||||
|
|
||||||
|
second = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "重复名称不应覆盖"}, format="json")
|
||||||
|
self.assertEqual(second.status_code, 200)
|
||||||
|
self.assertEqual(second.json()["id"], first.json()["id"])
|
||||||
|
self.assertEqual(Model.objects.filter(team=self.teamA, portrait_asset=portrait, is_deleted=False).count(), 1)
|
||||||
|
|
||||||
def test_official_flag_in_payload(self):
|
def test_official_flag_in_payload(self):
|
||||||
row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id))
|
row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id))
|
||||||
self.assertTrue(row["is_official"])
|
self.assertTrue(row["is_official"])
|
||||||
|
|
||||||
|
def test_update_own_model_profile_does_not_rewrite_portrait_asset(self):
|
||||||
|
portrait = Asset.objects.create(team=self.teamA, name="原始形象资产名", asset_type="image", source="upload")
|
||||||
|
self.mine.portrait_asset = portrait
|
||||||
|
self.mine.save(update_fields=["portrait_asset"])
|
||||||
|
|
||||||
|
res = self.client.patch(
|
||||||
|
f"/api/models/{self.mine.id}/",
|
||||||
|
{"name": "更新后的模特名", "description": "通勤风格"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.mine.refresh_from_db()
|
||||||
|
portrait.refresh_from_db()
|
||||||
|
self.assertEqual(self.mine.name, "更新后的模特名")
|
||||||
|
self.assertEqual(self.mine.description, "通勤风格")
|
||||||
|
self.assertEqual(portrait.name, "原始形象资产名")
|
||||||
|
|
||||||
|
def test_official_model_cannot_be_edited(self):
|
||||||
|
res = self.client.patch(f"/api/models/{self.official.id}/", {"name": "不应更新"}, format="json")
|
||||||
|
self.assertEqual(res.status_code, 403)
|
||||||
|
self.official.refresh_from_db()
|
||||||
|
self.assertEqual(self.official.name, "官方小美")
|
||||||
|
|
||||||
def test_delete_is_soft(self):
|
def test_delete_is_soft(self):
|
||||||
res = self.client.delete(f"/api/models/{self.mine.id}/")
|
res = self.client.delete(f"/api/models/{self.mine.id}/")
|
||||||
self.assertEqual(res.status_code, 204)
|
self.assertEqual(res.status_code, 204)
|
||||||
@@ -129,6 +180,103 @@ class ModelLibraryApiTests(TestCase):
|
|||||||
self.assertIsNotNone(model.portrait_asset)
|
self.assertIsNotNone(model.portrait_asset)
|
||||||
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
|
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
|
||||||
|
|
||||||
|
def test_upload_portrait_replaces_current_model_only_and_stays_out_of_library(self):
|
||||||
|
old_portrait = Asset.objects.create(
|
||||||
|
team=self.teamA, name="旧形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
|
||||||
|
)
|
||||||
|
self.mine.portrait_asset = old_portrait
|
||||||
|
self.mine.save(update_fields=["portrait_asset"])
|
||||||
|
stored = SimpleNamespace(object_key="teams/x/models/new.png", bucket="b", content_type="image/png", size_bytes=10)
|
||||||
|
|
||||||
|
with patch("apps.assets.views.TosStorage") as Tos:
|
||||||
|
Tos.return_value.upload_fileobj.return_value = stored
|
||||||
|
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
|
||||||
|
res = self.client.post(
|
||||||
|
f"/api/models/{self.mine.id}/upload-portrait/",
|
||||||
|
{"file": image, "name": "一次保存的新模特名", "description": "一次保存的新描述"},
|
||||||
|
format="multipart",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.mine.refresh_from_db()
|
||||||
|
self.assertNotEqual(self.mine.portrait_asset_id, old_portrait.id)
|
||||||
|
self.assertEqual(self.mine.name, "一次保存的新模特名")
|
||||||
|
self.assertEqual(self.mine.description, "一次保存的新描述")
|
||||||
|
new_portrait = self.mine.portrait_asset
|
||||||
|
self.assertEqual(new_portrait.category, Asset.Category.MODEL_PORTRAIT)
|
||||||
|
self.assertFalse(new_portrait.in_library)
|
||||||
|
self.assertTrue(Asset.objects.filter(id=old_portrait.id).exists())
|
||||||
|
self.assertEqual(Model.objects.filter(team=self.teamA).count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
self.mine.metadata["portrait_versions"],
|
||||||
|
[str(old_portrait.id), str(new_portrait.id)],
|
||||||
|
)
|
||||||
|
library_ids = {row["id"] for row in self.client.get("/api/assets/?page_size=200").json()["results"]}
|
||||||
|
self.assertNotIn(str(new_portrait.id), library_ids)
|
||||||
|
|
||||||
|
def test_official_model_cannot_replace_portrait(self):
|
||||||
|
with patch("apps.assets.views.TosStorage") as Tos:
|
||||||
|
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
|
||||||
|
res = self.client.post(f"/api/models/{self.official.id}/upload-portrait/", {"file": image}, format="multipart")
|
||||||
|
self.assertEqual(res.status_code, 403)
|
||||||
|
Tos.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ActorModelMigrationAuditTests(TestCase):
|
||||||
|
"""审计命令只能分类和输出,绝不能改写旧资产、项目或模特记录。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.products.models import Product
|
||||||
|
from apps.projects.models import BaseAssetGroup, Project
|
||||||
|
|
||||||
|
self.user, self.team = _mk_team("audit-u", "Audit")
|
||||||
|
self.eligible = Asset.objects.create(
|
||||||
|
team=self.team, name="旧保存演员", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
|
||||||
|
)
|
||||||
|
existing = Asset.objects.create(
|
||||||
|
team=self.team, name="已有模特形象", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
|
||||||
|
)
|
||||||
|
Model.objects.create(team=self.team, name="已有模特", portrait_asset=existing)
|
||||||
|
role = Asset.objects.create(
|
||||||
|
team=self.team, name="项目角色", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, in_library=True,
|
||||||
|
)
|
||||||
|
product = Product.objects.create(team=self.team, title="测试商品")
|
||||||
|
project = Project.objects.create(team=self.team, name="测试项目", product=product)
|
||||||
|
BaseAssetGroup.objects.create(project=project, kind=BaseAssetGroup.Kind.PERSON, adopted_asset=role)
|
||||||
|
Asset.objects.create(
|
||||||
|
team=self.team, name="垃圾桶演员", asset_type="image", source="upload", category=Asset.Category.PERSON, is_deleted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audit_is_read_only_and_excludes_project_and_existing_models(self):
|
||||||
|
before_assets = Asset.objects.count()
|
||||||
|
before_models = Model.objects.count()
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
call_command("audit_actor_model_migration", "--team-id", str(self.team.id), stdout=out)
|
||||||
|
|
||||||
|
report = out.getvalue()
|
||||||
|
self.assertIn("旧已保存人物候选: 1", report)
|
||||||
|
self.assertIn(str(self.eligible.id), report)
|
||||||
|
self.assertIn("垃圾桶 person(不迁移): 1", report)
|
||||||
|
self.assertIn("未创建、更新、删除任何记录或文件", report)
|
||||||
|
self.assertEqual(Asset.objects.count(), before_assets)
|
||||||
|
self.assertEqual(Model.objects.count(), before_models)
|
||||||
|
|
||||||
|
def test_apply_migrates_only_eligible_saved_actor_and_is_idempotent(self):
|
||||||
|
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
|
||||||
|
|
||||||
|
model = Model.objects.get(team=self.team, portrait_asset=self.eligible)
|
||||||
|
self.assertEqual(model.name, self.eligible.name)
|
||||||
|
self.assertEqual(model.metadata.get("migrated_from_legacy_actor"), True)
|
||||||
|
self.eligible.refresh_from_db()
|
||||||
|
self.assertEqual(self.eligible.category, Asset.Category.PERSON) # 历史分类不改
|
||||||
|
self.assertFalse(self.eligible.is_deleted)
|
||||||
|
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
|
||||||
|
self.assertFalse(Model.objects.filter(team=self.team, portrait_asset__name="项目角色").exists())
|
||||||
|
|
||||||
|
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
|
||||||
|
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class AssetSoftDeleteTests(TestCase):
|
class AssetSoftDeleteTests(TestCase):
|
||||||
"""资产库不展示软删资产(is_deleted=True):列表 + tab 计数都排除。"""
|
"""资产库不展示软删资产(is_deleted=True):列表 + tab 计数都排除。"""
|
||||||
@@ -148,6 +296,22 @@ class AssetSoftDeleteTests(TestCase):
|
|||||||
def test_summary_excludes_soft_deleted(self):
|
def test_summary_excludes_soft_deleted(self):
|
||||||
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
|
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
|
||||||
|
|
||||||
|
def test_functional_model_and_project_assets_do_not_appear_in_others(self):
|
||||||
|
model_portrait = Asset.objects.create(
|
||||||
|
team=self.team, name="模特形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
|
||||||
|
)
|
||||||
|
tri_view = Asset.objects.create(
|
||||||
|
team=self.team, name="角色三视图", asset_type="image", source="ai_generated", category=Asset.Category.TRI_VIEW,
|
||||||
|
)
|
||||||
|
other = Asset.objects.create(
|
||||||
|
team=self.team, name="其他图片", asset_type="image", source="upload", category=Asset.Category.UNCATEGORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = {row["id"] for row in self.client.get("/api/assets/?tab=others&page_size=50").json()["results"]}
|
||||||
|
self.assertIn(str(other.id), ids)
|
||||||
|
self.assertNotIn(str(model_portrait.id), ids)
|
||||||
|
self.assertNotIn(str(tri_view.id), ids)
|
||||||
|
|
||||||
|
|
||||||
class AssetTrashApiTests(TestCase):
|
class AssetTrashApiTests(TestCase):
|
||||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
|
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import StreamingHttpResponse
|
from django.http import StreamingHttpResponse
|
||||||
@@ -36,6 +37,9 @@ class AssetPagination(PageNumberPagination):
|
|||||||
_KNOWN_CATS = [
|
_KNOWN_CATS = [
|
||||||
"person", "scene", "product_image", "final_video", "upload",
|
"person", "scene", "product_image", "final_video", "upload",
|
||||||
"model_tryon", "platform_kit", "free_create",
|
"model_tryon", "platform_kit", "free_create",
|
||||||
|
# 模特资料、项目角色三视图/分镜是功能性引用资产,只在模特库或项目中管理,
|
||||||
|
# 不应落入 /library 的「其他」形成第二个人物入口。
|
||||||
|
"model_portrait", "tri_view", "storyboard", "voice",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -445,6 +449,10 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
qs = qs.filter(is_official=True)
|
qs = qs.filter(is_official=True)
|
||||||
elif tab == "mine":
|
elif tab == "mine":
|
||||||
qs = qs.filter(team=team, is_official=False)
|
qs = qs.filter(team=team, is_official=False)
|
||||||
|
# 项目角色详情用形象资产精确判断「是否已升级为模特」;不靠前端拉全量模特列表猜测,
|
||||||
|
# 避免模特数量超过单页上限时把已升级角色误显示成可重复升级。
|
||||||
|
if portrait_asset_id := self.request.query_params.get("portrait_asset"):
|
||||||
|
qs = qs.filter(portrait_asset_id=portrait_asset_id)
|
||||||
if self.request.query_params.get("q"):
|
if self.request.query_params.get("q"):
|
||||||
q = self.request.query_params["q"]
|
q = self.request.query_params["q"]
|
||||||
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
|
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
|
||||||
@@ -454,6 +462,14 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(team=self.get_team(), created_by=self.request.user)
|
serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
instance = serializer.instance
|
||||||
|
if instance.is_official:
|
||||||
|
raise PermissionDenied("官方模特不可编辑")
|
||||||
|
if instance.team_id != self.get_team().id:
|
||||||
|
raise PermissionDenied("无权编辑其他团队的模特")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if instance.is_official:
|
if instance.is_official:
|
||||||
raise PermissionDenied("官方模特不可删除")
|
raise PermissionDenied("官方模特不可删除")
|
||||||
@@ -462,6 +478,77 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
instance.is_deleted = True
|
instance.is_deleted = True
|
||||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="triview-quote")
|
||||||
|
def triview_quote(self, request, pk=None):
|
||||||
|
"""返回当前团队实际三视图价格;标准价 20 积分,含团队价格系数。"""
|
||||||
|
model = self.get_object()
|
||||||
|
if model.is_official:
|
||||||
|
raise PermissionDenied("官方模特不可生成三视图")
|
||||||
|
from apps.ai.services import quote_model_triview
|
||||||
|
|
||||||
|
try:
|
||||||
|
_config, quote = quote_model_triview(model=model)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError({"detail": str(exc)}) from exc
|
||||||
|
return Response({"points": str(quote.points)}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="generate-triview")
|
||||||
|
def generate_triview(self, request, pk=None):
|
||||||
|
"""提交团队模特三视图任务;9.4A 仅供接口与自动化测试,前端按钮尚未启用。"""
|
||||||
|
if not getattr(settings, "MODEL_TRIVIEW_GENERATION_ENABLED", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "模特三视图生成尚未启用,请先同步部署 worker"},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
model = self.get_object()
|
||||||
|
if model.is_official:
|
||||||
|
raise PermissionDenied("官方模特不可生成三视图")
|
||||||
|
from apps.ai.services import generate_model_triview
|
||||||
|
|
||||||
|
try:
|
||||||
|
task, created = generate_model_triview(model=model, user=request.user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError({"detail": str(exc)}) from exc
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"task_id": str(task.id),
|
||||||
|
"status": task.status,
|
||||||
|
"estimated_cost": str(task.estimated_cost),
|
||||||
|
"reused": not created,
|
||||||
|
},
|
||||||
|
status=status.HTTP_202_ACCEPTED if created else status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="triview-status")
|
||||||
|
def triview_status(self, request, pk=None):
|
||||||
|
"""轮询指定模特三视图任务,只允许读取本团队且 payload 归属当前模特的任务。"""
|
||||||
|
model = self.get_object()
|
||||||
|
task_id = request.query_params.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise ValidationError({"task_id": "缺少任务 id"})
|
||||||
|
from apps.ai.models import AITask
|
||||||
|
|
||||||
|
task = AITask.objects.filter(
|
||||||
|
id=task_id,
|
||||||
|
team=self.get_team(),
|
||||||
|
task_type=AITask.Type.MODEL_TRIVIEW,
|
||||||
|
request_payload__model_id=str(model.id),
|
||||||
|
).first()
|
||||||
|
if task is None:
|
||||||
|
raise ValidationError({"task_id": "任务不存在或不属于当前模特"})
|
||||||
|
model.refresh_from_db()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"task_id": str(task.id),
|
||||||
|
"status": task.status,
|
||||||
|
"estimated_cost": str(task.estimated_cost),
|
||||||
|
"actual_cost": str(task.actual_cost),
|
||||||
|
"error_message": task.error_message,
|
||||||
|
"model": ModelLibrarySerializer(model).data if task.status == AITask.Status.SUCCEEDED else None,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="trash")
|
@action(detail=False, methods=["get"], url_path="trash")
|
||||||
def trash(self, request):
|
def trash(self, request):
|
||||||
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
|
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
|
||||||
@@ -533,6 +620,64 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
)
|
)
|
||||||
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
|
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="upload-portrait", parser_classes=[MultiPartParser, FormParser])
|
||||||
|
def upload_portrait(self, request, pk=None):
|
||||||
|
"""显式维护已有模特的形象图:上传新 model_portrait 并切换当前引用;不新建模特、不回写项目角色。"""
|
||||||
|
model = self.get_object()
|
||||||
|
if model.is_official:
|
||||||
|
raise PermissionDenied("官方模特不可编辑")
|
||||||
|
team = self.get_team()
|
||||||
|
if model.team_id != team.id:
|
||||||
|
raise PermissionDenied("无权编辑其他团队的模特")
|
||||||
|
upload = request.FILES.get("file")
|
||||||
|
if upload is None:
|
||||||
|
raise ValidationError({"file": "请上传一张模特形象图"})
|
||||||
|
next_name = str(request.data.get("name", model.name)).strip()[:255]
|
||||||
|
if not next_name:
|
||||||
|
raise ValidationError({"name": "模特名称不能为空"})
|
||||||
|
next_description = str(request.data.get("description", model.description or "")).strip()
|
||||||
|
|
||||||
|
asset_id = uuid.uuid4()
|
||||||
|
suffix = Path(upload.name).suffix.lower() or ".png"
|
||||||
|
object_key = f"teams/{team.id}/models/{model.id}/portraits/{asset_id}{suffix}"
|
||||||
|
with transaction.atomic():
|
||||||
|
stored = TosStorage().upload_fileobj(
|
||||||
|
fileobj=upload.file,
|
||||||
|
object_key=object_key,
|
||||||
|
content_type=upload.content_type or "image/png",
|
||||||
|
)
|
||||||
|
portrait = Asset.objects.create(
|
||||||
|
id=asset_id,
|
||||||
|
team=team,
|
||||||
|
created_by=request.user,
|
||||||
|
name=f"{next_name}·形象图",
|
||||||
|
asset_type=Asset.Type.IMAGE,
|
||||||
|
source=Asset.Source.UPLOAD,
|
||||||
|
category=Asset.Category.MODEL_PORTRAIT,
|
||||||
|
in_library=False,
|
||||||
|
metadata={"kind": "model", "view": "frontal", "source": "upload", "model_id": str(model.id)},
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=portrait,
|
||||||
|
object_key=stored.object_key,
|
||||||
|
bucket=stored.bucket,
|
||||||
|
content_type=stored.content_type,
|
||||||
|
size_bytes=stored.size_bytes,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
versions = [str(value) for value in (model.metadata or {}).get("portrait_versions", []) if value]
|
||||||
|
for value in (model.portrait_asset_id, portrait.id):
|
||||||
|
if value and str(value) not in versions:
|
||||||
|
versions.append(str(value))
|
||||||
|
metadata = dict(model.metadata or {})
|
||||||
|
metadata["portrait_versions"] = versions
|
||||||
|
model.name = next_name
|
||||||
|
model.description = next_description
|
||||||
|
model.portrait_asset = portrait
|
||||||
|
model.metadata = metadata
|
||||||
|
model.save(update_fields=["name", "description", "portrait_asset", "metadata", "updated_at"])
|
||||||
|
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="enroll-asset")
|
@action(detail=False, methods=["post"], url_path="enroll-asset")
|
||||||
def enroll_asset(self, request):
|
def enroll_asset(self, request):
|
||||||
"""图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目。
|
"""图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目。
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ _STAGE_BUCKET = {
|
|||||||
AITask.Type.ENTITY_EXTRACTION: "script",
|
AITask.Type.ENTITY_EXTRACTION: "script",
|
||||||
AITask.Type.PRODUCT_IMAGE: "base",
|
AITask.Type.PRODUCT_IMAGE: "base",
|
||||||
AITask.Type.PERSON_IMAGE: "base",
|
AITask.Type.PERSON_IMAGE: "base",
|
||||||
|
AITask.Type.MODEL_TRIVIEW: "base",
|
||||||
AITask.Type.SCENE_IMAGE: "base",
|
AITask.Type.SCENE_IMAGE: "base",
|
||||||
AITask.Type.STORYBOARD: "storyboard",
|
AITask.Type.STORYBOARD: "storyboard",
|
||||||
AITask.Type.VIDEO_SEGMENT: "video",
|
AITask.Type.VIDEO_SEGMENT: "video",
|
||||||
|
|||||||
@@ -349,6 +349,33 @@ class ProjectApiTests(TestCase):
|
|||||||
group = BaseAssetGroup.objects.get(project=project, kind=BaseAssetGroup.Kind.PERSON)
|
group = BaseAssetGroup.objects.get(project=project, kind=BaseAssetGroup.Kind.PERSON)
|
||||||
self.assertEqual(group.metadata.get("label"), "女主")
|
self.assertEqual(group.metadata.get("label"), "女主")
|
||||||
|
|
||||||
|
@patch("apps.ai.services._store_generated_media")
|
||||||
|
@patch("apps.ai.services.get_image_provider")
|
||||||
|
def test_generate_base_asset_ignores_auto_triview_request(self, get_provider, store_media):
|
||||||
|
ModelConfig.objects.create(
|
||||||
|
provider=self.provider, name="img-model-auto-tri", display_name="Img Auto Tri",
|
||||||
|
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||||
|
)
|
||||||
|
asset = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="person",
|
||||||
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||||
|
)
|
||||||
|
store_media.return_value = asset
|
||||||
|
provider = get_provider.return_value
|
||||||
|
provider.image_generation.return_value = {"data": [{"url": "http://x/img.png"}]}
|
||||||
|
provider.extract_first_media_url.return_value = "http://x/img.png"
|
||||||
|
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-auto-tri")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/projects/{project.id}/generate-base-asset/",
|
||||||
|
{"kind": "person", "prompt": "portrait", "label": "hero", "auto_triview": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
task = AITask.objects.get(id=response.data["task"]["id"])
|
||||||
|
self.assertFalse(task.request_payload.get("auto_triview"))
|
||||||
|
|
||||||
@patch("apps.ai.services._store_generated_media")
|
@patch("apps.ai.services._store_generated_media")
|
||||||
@patch("apps.ai.services.get_image_provider")
|
@patch("apps.ai.services.get_image_provider")
|
||||||
def test_product_base_asset_uses_cover_image_as_reference(self, get_provider, store_media):
|
def test_product_base_asset_uses_cover_image_as_reference(self, get_provider, store_media):
|
||||||
|
|||||||
@@ -542,9 +542,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
prompt=request.data.get("prompt", ""),
|
prompt=request.data.get("prompt", ""),
|
||||||
label=request.data.get("label", ""),
|
label=request.data.get("label", ""),
|
||||||
reference_asset_id=request.data.get("reference_asset_id") or None,
|
reference_asset_id=request.data.get("reference_asset_id") or None,
|
||||||
# ZWQ#3:角色立绘可让 worker 出图后自动接力生成三视图。前端勾选时传 auto_triview=true;
|
# 角色立绘不再自动接力三视图;三视图只由角色详情里的显式按钮生成。
|
||||||
# 不传则维持原行为(只出立绘,用户再手点「AI 生成三视图」)。
|
auto_triview=False,
|
||||||
auto_triview=bool(request.data.get("auto_triview")),
|
|
||||||
)
|
)
|
||||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|||||||
@@ -996,18 +996,18 @@ export function App() {
|
|||||||
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
|
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
|
||||||
return assetId ? { id: assetId } : null;
|
return assetId ? { id: assetId } : null;
|
||||||
}}
|
}}
|
||||||
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
onGenerateModel={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
||||||
onUploadActor={(file) => {
|
onUploadModel={(file) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
fd.append("name", file.name);
|
fd.append("name", file.name);
|
||||||
fd.append("asset_type", "image");
|
fd.append("asset_type", "image");
|
||||||
fd.append("category", "person");
|
fd.append("category", "model_portrait");
|
||||||
// 返回上传后的 Asset(供添加人物工作台进右侧栏做三视图/命名)
|
// 返回上传后的模特形象 Asset,保存模特时复用它创建 Model。
|
||||||
return action(() => api.uploadAsset(fd), "");
|
return action(() => api.uploadAsset(fd), "");
|
||||||
}}
|
}}
|
||||||
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
|
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
|
||||||
onRenameActor={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
|
onRenameModel={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
|
||||||
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
|
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
|
||||||
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
|
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
|
||||||
onSubmitAllVideos={(prompt) =>
|
onSubmitAllVideos={(prompt) =>
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export const api = {
|
|||||||
deleteBaseAsset(projectId: string, groupId: string) {
|
deleteBaseAsset(projectId: string, groupId: string) {
|
||||||
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
|
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
|
||||||
},
|
},
|
||||||
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
|
// 流程步骤4 · 用模特库现有形象资产替换角色基础资产卡(挂为候选并采用)
|
||||||
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
|
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
|
||||||
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
|
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
|
||||||
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
@@ -530,7 +530,7 @@ export const api = {
|
|||||||
submitAssetReview(id: string) {
|
submitAssetReview(id: string) {
|
||||||
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
||||||
},
|
},
|
||||||
// 置 in_library(现仅演员库「保存人物」用:立绘暂存 → 入库;R109 后图片生成成图已自动入库)
|
// 置 in_library(功能性模特/角色资料不走该入口;图片成图仍按原规则入库)
|
||||||
setAssetLibrary(id: string, inLibrary: boolean) {
|
setAssetLibrary(id: string, inLibrary: boolean) {
|
||||||
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
|
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
|
||||||
},
|
},
|
||||||
@@ -588,15 +588,16 @@ export const api = {
|
|||||||
uploadAsset(formData: FormData) {
|
uploadAsset(formData: FormData) {
|
||||||
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
||||||
},
|
},
|
||||||
// 流程步骤4 · 给人物/资产改名(添加人物工作台命名 → PATCH 写回 name)
|
// 流程步骤4 · 给模特形象/资产改名(添加模特工作台命名 → PATCH 写回 name)
|
||||||
updateAsset(id: string, payload: { name?: string; description?: string }) {
|
updateAsset(id: string, payload: { name?: string; description?: string }) {
|
||||||
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
// 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建
|
// 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建
|
||||||
listModels(params: { tab?: "official" | "mine"; q?: string; pageSize?: number } = {}) {
|
listModels(params: { tab?: "official" | "mine"; q?: string; portraitAsset?: string; pageSize?: number } = {}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.tab) qs.set("tab", params.tab);
|
if (params.tab) qs.set("tab", params.tab);
|
||||||
if (params.q) qs.set("q", params.q);
|
if (params.q) qs.set("q", params.q);
|
||||||
|
if (params.portraitAsset) qs.set("portrait_asset", params.portraitAsset);
|
||||||
qs.set("page_size", String(params.pageSize ?? 200));
|
qs.set("page_size", String(params.pageSize ?? 200));
|
||||||
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
|
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
@@ -604,10 +605,18 @@ export const api = {
|
|||||||
uploadModel(formData: FormData) {
|
uploadModel(formData: FormData) {
|
||||||
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
|
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
|
||||||
},
|
},
|
||||||
|
// /models 详情:上传并替换当前模特形象图,不创建新模特。
|
||||||
|
uploadModelPortrait(id: string, formData: FormData) {
|
||||||
|
return request<ModelEntity>(`/api/models/${id}/upload-portrait/`, { method: "POST", body: formData });
|
||||||
|
},
|
||||||
// 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等)
|
// 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等)
|
||||||
enrollModelFromAsset(assetId: string, name?: string) {
|
enrollModelFromAsset(assetId: string, name?: string) {
|
||||||
return request<ModelEntity>("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) });
|
return request<ModelEntity>("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) });
|
||||||
},
|
},
|
||||||
|
// /models 模特详情编辑:只更新模特实体资料,不回写项目角色或底层图片资产。
|
||||||
|
updateLibraryModel(id: string, payload: { name?: string; description?: string }) {
|
||||||
|
return request<ModelEntity>(`/api/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
// 删除模特(软删;官方模特不可删)
|
// 删除模特(软删;官方模特不可删)
|
||||||
deleteModel(id: string) {
|
deleteModel(id: string) {
|
||||||
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
|
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
|
||||||
|
|||||||
+46
-118
@@ -5,52 +5,43 @@ import { api } from "../api";
|
|||||||
import type { Asset, ModelEntity } from "../types";
|
import type { Asset, ModelEntity } from "../types";
|
||||||
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||||
|
|
||||||
// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),
|
// 模特选择器:只读取 Model 顶级实体(含官方跨团队模特),不再混入普通 person 资产。
|
||||||
// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产。
|
// 两种用法:browse(浏览 / 添加模特)与 replace(选用一个模特回填当前角色或图片生成)。
|
||||||
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从库里选一个回填到某基础资产卡)。
|
|
||||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
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 || "";
|
const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||||
// 模特库实体 → Asset 形(id=形象图资产 id,选中即作角色参考图);标 kind=model 归「模特库」tab
|
// 模特库实体 → 供现有卡片复用的 Asset 形;id 保持形象资产 id,选中后可直接作为参考图。
|
||||||
const modelToAsset = (m: ModelEntity): Asset => ({
|
const modelToAsset = (m: ModelEntity): Asset => ({
|
||||||
id: m.portrait_asset as string,
|
id: m.portrait_asset as string,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
asset_type: "image",
|
asset_type: "image",
|
||||||
source: "ai_generated",
|
source: m.source === "upload" ? "upload" : "ai_generated",
|
||||||
category: "person",
|
category: "model_portrait",
|
||||||
description: "",
|
description: "",
|
||||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
|
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
|
||||||
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
|
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
updated_at: m.updated_at
|
updated_at: m.updated_at
|
||||||
});
|
});
|
||||||
// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」
|
export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGenerate, onUpload, onRename, onRefresh }: {
|
||||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model";
|
|
||||||
|
|
||||||
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
mode: "browse" | "replace";
|
mode: "browse" | "replace";
|
||||||
initialStudio?: boolean; // 打开即进「添加人物工作台」
|
initialStudio?: boolean; // 打开即进「添加模特工作台」
|
||||||
assets: Asset[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick?: (assetId: string, assetName?: string) => void | Promise<unknown>;
|
onPick?: (assetId: string, assetName?: string) => void | Promise<unknown>;
|
||||||
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||||
onUpload: (file: File) => Promise<Asset | null>;
|
onUpload: (file: File) => Promise<Asset | null>;
|
||||||
// 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group)
|
onRename?: (assetId: string, name: string) => Promise<unknown>; // 同步底层形象资产名称
|
||||||
onGenerateTriview?: (portraitAssetId: string) => Promise<{ id: string } | null>;
|
|
||||||
onRename?: (assetId: string, name: string) => Promise<unknown>; // 给人物命名(写回资产 name)
|
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<"preset" | "mine">("preset");
|
const [studio, setStudio] = useState(Boolean(initialStudio)); // 添加模特工作台
|
||||||
const [studio, setStudio] = useState(Boolean(initialStudio)); // 添加演员工作台
|
|
||||||
const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai");
|
const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
// 添加人物工作台多步态:候选立绘 + 选中那张 + 命名 + 三视图就绪标志
|
// 添加模特工作台多步态:候选形象图 + 选中图 + 模特名称。
|
||||||
const [candidates, setCandidates] = useState<Asset[]>([]); // AI 生成/上传得到的立绘候选
|
const [candidates, setCandidates] = useState<Asset[]>([]); // AI 生成/上传得到的立绘候选
|
||||||
const [picked, setPicked] = useState<Asset | null>(null); // 进右侧栏的那张立绘
|
const [picked, setPicked] = useState<Asset | null>(null); // 进右侧栏的那张立绘
|
||||||
const [actorName, setActorName] = useState("");
|
const [modelName, setModelName] = useState("");
|
||||||
const [triReady, setTriReady] = useState(false); // 已据选中立绘生成过三视图
|
|
||||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,46 +55,32 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setStudio(Boolean(initialStudio));
|
setStudio(Boolean(initialStudio));
|
||||||
setStudioMode("ai");
|
setStudioMode("ai");
|
||||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
setCandidates([]); setPicked(null); setModelName("");
|
||||||
}, [open, initialStudio]);
|
}, [open, initialStudio]);
|
||||||
|
|
||||||
// 演员库自取 person 资产:全局 assets 已不再全量预载(按需懒加载),不能再依赖传入的 assets prop,
|
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
|
||||||
// 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。
|
|
||||||
const [fetched, setFetched] = useState<Asset[]>([]);
|
const [fetched, setFetched] = useState<Asset[]>([]);
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
// 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出,
|
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
|
||||||
// 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。
|
|
||||||
const [persons, models] = await Promise.all([
|
|
||||||
api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null),
|
|
||||||
api.listModels({ pageSize: 200 }).catch(() => null)
|
|
||||||
]);
|
|
||||||
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
||||||
setFetched([...(persons?.results ?? []), ...mapped]);
|
setFetched(mapped);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
||||||
|
|
||||||
// person 类资产 → 演员;按 preset / mine 分两 tab。合并 prop(兼容旧调用)+ 自取,按 id 去重。
|
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
|
||||||
const source = useMemo(() => {
|
|
||||||
const map = new Map<string, Asset>();
|
|
||||||
for (const a of [...assets, ...fetched]) map.set(a.id, a);
|
|
||||||
return [...map.values()];
|
|
||||||
}, [assets, fetched]);
|
|
||||||
const people = useMemo(() => source.filter((a) => a.category === "person" && previewOf(a)), [source]);
|
|
||||||
const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a)));
|
|
||||||
|
|
||||||
// 演员库分页(客户端):一页 10 个(5 列 × 2 行)。切 tab / 列表变化时回第 1 页,避免停在越界页。
|
// 模特库分页(客户端):一页 10 个(5 列 × 2 行)。
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
||||||
useEffect(() => { setPage(1); }, [tab, open]);
|
useEffect(() => { setPage(1); }, [open]);
|
||||||
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
|
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
|
||||||
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
// 选某张候选立绘 → 进右侧栏(预填名字,清三视图标志)
|
// 选某张候选形象图 → 进右侧栏命名。
|
||||||
function pickCandidate(a: Asset) {
|
function pickCandidate(a: Asset) {
|
||||||
setPicked(a);
|
setPicked(a);
|
||||||
setActorName(a.name || "");
|
setModelName(a.name || "");
|
||||||
setTriReady(false);
|
|
||||||
}
|
}
|
||||||
// AI 生成立绘候选(不直接关 studio,等用户选一张进下一步)
|
// AI 生成立绘候选(不直接关 studio,等用户选一张进下一步)
|
||||||
async function genCandidates() {
|
async function genCandidates() {
|
||||||
@@ -125,55 +102,28 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
if (asset && previewOf(asset)) { setCandidates([asset]); pickCandidate(asset); }
|
if (asset && previewOf(asset)) { setCandidates([asset]); pickCandidate(asset); }
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
// 据选中立绘生成三视图(后端异步出图,完成后刷新即见;本地无 worker 时点了不报错)
|
// 保存模特:同步名称 → 复用当前形象资产创建 Model → 刷新模特库。
|
||||||
async function genTriview() {
|
async function saveModel() {
|
||||||
if (!picked || !onGenerateTriview) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const r = await onGenerateTriview(picked.id);
|
|
||||||
if (r) setTriReady(true);
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
}
|
|
||||||
// 保存人物:写名字(若有改名能力)→ 加入资产库 → 刷新 → 关工作台 → 切「我的演员」tab
|
|
||||||
async function saveActor() {
|
|
||||||
if (!picked) return;
|
if (!picked) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const name = actorName.trim();
|
const name = modelName.trim();
|
||||||
if (name && name !== picked.name && onRename) await onRename(picked.id, name);
|
if (name && name !== picked.name && onRename) await onRename(picked.id, name);
|
||||||
// 关键:AI 生成/上传得到的立绘默认 in_library=False(工作台暂存),不入「我的演员」列表(只列已入库 person)。
|
await api.enrollModelFromAsset(picked.id, name || undefined);
|
||||||
// 「保存人物」必须把它加入资产库,否则保存后「我的演员」始终 0 个演员(ZWQ#6)。
|
|
||||||
await api.setAssetLibrary(picked.id, true).catch(() => undefined);
|
|
||||||
onRefresh();
|
onRefresh();
|
||||||
await reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets)
|
await reload(); // 新建模特即时进入唯一的模特列表
|
||||||
setStudio(false);
|
setStudio(false);
|
||||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
setCandidates([]); setPicked(null); setModelName("");
|
||||||
setTab("mine");
|
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除「我的演员」(软删 person 资产)· 两步确认,3s 自动撤销(ZWQ#8)
|
|
||||||
const [delArmId, setDelArmId] = useState<string | null>(null);
|
|
||||||
const [delBusyId, setDelBusyId] = useState<string | null>(null);
|
|
||||||
async function deleteActor(id: string) {
|
|
||||||
setDelBusyId(id);
|
|
||||||
try {
|
|
||||||
await api.deleteAsset(id);
|
|
||||||
onRefresh();
|
|
||||||
await reload();
|
|
||||||
} catch { /* 删除失败保留卡片,可重试 */ } finally {
|
|
||||||
setDelBusyId(null);
|
|
||||||
setDelArmId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className="actorlib" role="dialog" aria-modal="true" aria-label="演员库">
|
<div className="actorlib" role="dialog" aria-modal="true" aria-label="模特库">
|
||||||
<div className="actorlib-h">
|
<div className="actorlib-h">
|
||||||
<h2>{mode === "replace" ? "演员库 · 选择演员" : "演员库"}</h2>
|
<h2>{mode === "replace" ? "模特库 · 选择模特" : "模特库"}</h2>
|
||||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即选用应用" : "平台预设演员 / 我的演员"}</span>
|
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
|
||||||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -184,20 +134,20 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
<div className="actorlib-studio-h">
|
<div className="actorlib-studio-h">
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setStudio(false)}>
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setStudio(false)}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
|
||||||
返回演员库
|
返回模特库
|
||||||
</button>
|
</button>
|
||||||
<strong style={{ fontSize: 14 }}>添加人物工作台</strong>
|
<strong style={{ fontSize: 14 }}>添加模特</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
|
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
|
||||||
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("ai"); setCandidates([]); setPicked(null); }}>AI 生成</button>
|
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("ai"); setCandidates([]); setPicked(null); }}>AI 生成</button>
|
||||||
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("upload"); setCandidates([]); setPicked(null); }}>本地上传</button>
|
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("upload"); setCandidates([]); setPicked(null); }}>本地上传</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 多步工作台:左 = 生成/上传 + 立绘候选;右 = 选中立绘的命名/三视图/保存 */}
|
{/* 多步工作台:左 = 生成/上传 + 形象图候选;右 = 模特命名/保存。 */}
|
||||||
<div className="actorlib-studio-grid">
|
<div className="actorlib-studio-grid">
|
||||||
<div className="actorlib-studio-left">
|
<div className="actorlib-studio-left">
|
||||||
{studioMode === "ai" ? (
|
{studioMode === "ai" ? (
|
||||||
<div className="actorlib-studio-ai">
|
<div className="actorlib-studio-ai">
|
||||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述演员形象(年龄 / 风格 / 妆造 / 背景)→ 生成立绘候选</div>
|
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选</div>
|
||||||
<textarea className="asset-prompt-edit" rows={3} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
<textarea className="asset-prompt-edit" rows={3} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
|
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
|
||||||
{busy ? "生成中…" : "生成立绘"}
|
{busy ? "生成中…" : "生成立绘"}
|
||||||
@@ -205,10 +155,10 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actorlib-studio-upload">
|
<div className="actorlib-studio-upload">
|
||||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地人物图片 → 进右侧栏生成三视图 / 命名</div>
|
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地模特形象图 → 右侧命名并保存</div>
|
||||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地人物图片上传"}</div>
|
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
|
||||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||||
@@ -216,7 +166,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
)}
|
)}
|
||||||
{candidates.length > 0 && (
|
{candidates.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张立绘 → 右侧命名 / 生成三视图 / 保存</div>
|
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张形象图 → 右侧命名并保存模特</div>
|
||||||
<div className="actorlib-grid">
|
<div className="actorlib-grid">
|
||||||
{candidates.map((a) => {
|
{candidates.map((a) => {
|
||||||
const url = previewOf(a);
|
const url = previewOf(a);
|
||||||
@@ -242,21 +192,15 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
|
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
|
||||||
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
|
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给人物命名</label>
|
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给模特命名</label>
|
||||||
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={actorName} onChange={(e) => setActorName(e.target.value)} />
|
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={modelName} onChange={(e) => setModelName(e.target.value)} />
|
||||||
{onGenerateTriview && (
|
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveModel()}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genTriview()}>
|
{busy ? "保存中…" : "保存模特"}
|
||||||
{busy ? "提交中…" : triReady ? "已提交三视图 · 再生成一版" : "生成三视图"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{triReady && <div className="muted mono" style={{ fontSize: 12, marginTop: 6 }}>// 三视图出图在后台进行,保存后回人物卡详情可查看</div>}
|
|
||||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveActor()}>
|
|
||||||
{busy ? "保存中…" : "保存人物"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
|
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
|
||||||
<span className="ph-frame">// {studioMode === "ai" ? "生成立绘后,选一张进入这里" : "上传图片后,在这里命名 / 生成三视图"}</span>
|
<span className="ph-frame">// {studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -265,14 +209,11 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
) : (
|
) : (
|
||||||
<div className="actorlib-body">
|
<div className="actorlib-body">
|
||||||
<div className="actorlib-toolbar">
|
<div className="actorlib-toolbar">
|
||||||
<div className="actorlib-tabs">
|
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 共 {list.length} 位模特</span>
|
||||||
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}>模特库 · {people.filter(isPreset).length}</button>
|
|
||||||
<button className={`al-tab${tab === "mine" ? " active" : ""}`} type="button" onClick={() => setTab("mine")}>我的演员 · {people.filter((a) => !isPreset(a)).length}</button>
|
|
||||||
</div>
|
|
||||||
<span className="spacer" style={{ flex: 1 }}></span>
|
<span className="spacer" style={{ flex: 1 }}></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
|
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
|
||||||
添加演员
|
添加模特
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{list.length ? (
|
{list.length ? (
|
||||||
@@ -280,23 +221,10 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
<div className="actorlib-grid">
|
<div className="actorlib-grid">
|
||||||
{pageList.map((a) => {
|
{pageList.map((a) => {
|
||||||
const url = previewOf(a);
|
const url = previewOf(a);
|
||||||
// 「我的演员」(非模特库预设)可删:右上角垃圾桶,两步确认(ZWQ#8)
|
|
||||||
const canDelete = tab === "mine" && !isPreset(a);
|
|
||||||
return (
|
return (
|
||||||
<div className="actor-card" key={a.id} style={{ position: "relative" }}>
|
<div className="actor-card" key={a.id}>
|
||||||
{canDelete && (delArmId === a.id ? (
|
|
||||||
<span className="asset-card-del-confirm" data-stop style={{ position: "absolute", top: 6, right: 6, zIndex: 3, display: "flex", gap: 4 }}>
|
|
||||||
<button type="button" className="del-yes" disabled={delBusyId === a.id} onClick={(e) => { e.stopPropagation(); void deleteActor(a.id); }}>{delBusyId === a.id ? "删除中…" : "确认删除"}</button>
|
|
||||||
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmId(null); }}>取消</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button type="button" className="asset-card-del" title="删除该演员" style={{ position: "absolute", top: 6, right: 6, zIndex: 3 }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setDelArmId(a.id); window.setTimeout(() => setDelArmId((v) => (v === a.id ? null : v)), 3000); }}>
|
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||||
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员" : "查看大图"}
|
role="button" tabIndex={0} title={mode === "replace" ? "选用此模特" : "查看大图"}
|
||||||
onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }}
|
onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
|
||||||
{!url && <span className="ph-frame">{a.name}</span>}
|
{!url && <span className="ph-frame">{a.name}</span>}
|
||||||
@@ -317,7 +245,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||||
<span className="ph-frame">// {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
<span className="ph-frame">// 模特库暂无模特 · 点右上“添加模特”创建</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,10 +113,31 @@
|
|||||||
letter-spacing: .02em;
|
letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 模特详情弹窗:继承 .modal(不重写),只加宽度 modifier(同 .invite-modal 惯例) */
|
/* 模特详情弹窗:参考视频项目角色详情结构;当前小步只开放名称/描述编辑 */
|
||||||
.model-detail-modal { max-width: 560px; }
|
.modal.model-detail-modal {
|
||||||
|
width: min(880px, calc(100vw - 80px));
|
||||||
.model-detail-b { display: flex; flex-direction: column; gap: 14px; }
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
.model-detail-b {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
.model-detail-left,
|
||||||
|
.model-detail-right { min-width: 0; }
|
||||||
|
.model-detail-right { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.model-detail-section-h {
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.model-detail-section-h strong { font-size: 13px; font-weight: 500; color: var(--accent-black); }
|
||||||
|
.model-detail-section-h .mono { font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; }
|
||||||
|
.model-detail-section-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
/* 形象图大图:3:4 竖版,与卡片缩图比例一致 */
|
/* 形象图大图:3:4 竖版,与卡片缩图比例一致 */
|
||||||
.model-detail-portrait {
|
.model-detail-portrait {
|
||||||
@@ -130,19 +151,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
margin-top: 12px;
|
||||||
|
|
||||||
.model-detail-desc {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--black-alpha-64);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-detail-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--black-alpha-48);
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 三视图:16:9 单容器(不拆 3 张) */
|
/* 三视图:16:9 单容器(不拆 3 张) */
|
||||||
@@ -152,3 +161,13 @@
|
|||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.model-detail-triview[role="button"] { cursor: zoom-in; }
|
.model-detail-triview[role="button"] { cursor: zoom-in; }
|
||||||
|
.model-detail-form { padding-top: 20px; border-top: 1px solid var(--border-faint); }
|
||||||
|
.model-detail-form .field:last-of-type { margin-bottom: 10px; }
|
||||||
|
.model-detail-form .textarea { resize: vertical; }
|
||||||
|
.model-detail-readonly { margin-right: auto; font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.modal.model-detail-modal { width: calc(100vw - 32px); }
|
||||||
|
.model-detail-b { grid-template-columns: 1fr; padding: 20px; }
|
||||||
|
.model-detail-left { width: min(100%, 320px); margin: 0 auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { ActorLibrary } from "../components/actor-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { SkeletonRows } from "../components/loading";
|
import { SkeletonRows } from "../components/loading";
|
||||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
@@ -664,8 +664,8 @@ export function ImageWorkbenchPage({
|
|||||||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||||||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||||||
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||||||
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
|
// 模特库全屏弹窗:选一个模特回填。
|
||||||
const [actorLibOpen, setActorLibOpen] = useState(false);
|
const [modelLibOpen, setModelLibOpen] = useState(false);
|
||||||
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
|
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
|
||||||
const [pickedModelName, setPickedModelName] = useState("");
|
const [pickedModelName, setPickedModelName] = useState("");
|
||||||
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
|
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
|
||||||
@@ -749,7 +749,7 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
||||||
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
||||||
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
|
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ModelLibrary 弹窗同源。 */
|
||||||
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
|
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
|
||||||
const loadModels = useCallback(() => {
|
const loadModels = useCallback(() => {
|
||||||
api.listModels({ pageSize: 200 })
|
api.listModels({ pageSize: 200 })
|
||||||
@@ -1854,7 +1854,7 @@ export function ImageWorkbenchPage({
|
|||||||
const name = selected?.name || pickedModelName;
|
const name = selected?.name || pickedModelName;
|
||||||
if (pickedIds[0]) {
|
if (pickedIds[0]) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setActorLibOpen(true)}>
|
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setModelLibOpen(true)}>
|
||||||
<div className="m-thumb">
|
<div className="m-thumb">
|
||||||
{url ? (
|
{url ? (
|
||||||
<img className="m-thumb-img" src={url} alt={name} loading="lazy" />
|
<img className="m-thumb-img" src={url} alt={name} loading="lazy" />
|
||||||
@@ -1869,7 +1869,7 @@ export function ImageWorkbenchPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setActorLibOpen(true)}>
|
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setModelLibOpen(true)}>
|
||||||
<div className="m-thumb m-thumb-empty">
|
<div className="m-thumb m-thumb-empty">
|
||||||
<Plus size={26} />
|
<Plus size={26} />
|
||||||
<span className="m-empty-label">从模特库选择</span>
|
<span className="m-empty-label">从模特库选择</span>
|
||||||
@@ -2046,17 +2046,16 @@ export function ImageWorkbenchPage({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
|
{/* 模特库全屏弹窗:只选 Model,新增模特后刷新并回填。 */}
|
||||||
<ActorLibrary
|
<ModelLibrary
|
||||||
open={actorLibOpen}
|
open={modelLibOpen}
|
||||||
mode="replace"
|
mode="replace"
|
||||||
assets={personAssets}
|
onClose={() => setModelLibOpen(false)}
|
||||||
onClose={() => setActorLibOpen(false)}
|
|
||||||
onPick={(assetId, assetName) => {
|
onPick={(assetId, assetName) => {
|
||||||
setPickedIds([assetId]);
|
setPickedIds([assetId]);
|
||||||
setPickedModelName(assetName || "");
|
setPickedModelName(assetName || "");
|
||||||
loadModels();
|
loadModels();
|
||||||
setActorLibOpen(false);
|
setModelLibOpen(false);
|
||||||
}}
|
}}
|
||||||
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
|
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
|
||||||
onUpload={(file) => {
|
onUpload={(file) => {
|
||||||
@@ -2064,7 +2063,7 @@ export function ImageWorkbenchPage({
|
|||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
fd.append("name", file.name);
|
fd.append("name", file.name);
|
||||||
fd.append("asset_type", "image");
|
fd.append("asset_type", "image");
|
||||||
fd.append("category", "person");
|
fd.append("category", "model_portrait");
|
||||||
return api.uploadAsset(fd).catch(() => null);
|
return api.uploadAsset(fd).catch(() => null);
|
||||||
}}
|
}}
|
||||||
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
|
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
|
||||||
@@ -2218,7 +2217,7 @@ function ProductLibraryModal({
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
navigate?: (page: Page) => void;
|
navigate?: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
// Esc 关闭 + body 滚动锁(与 ActorLibrary 一致体感)
|
// Esc 关闭 + body 滚动锁(与 ModelLibrary 一致体感)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
|
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { User, X } from "lucide-react";
|
import { RefreshCw, Upload, User, X } from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { ModelEntity } from "../types";
|
import type { ModelEntity } from "../types";
|
||||||
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
@@ -20,41 +20,136 @@ type Preview = { src: string; kind: "image"; name: string };
|
|||||||
|
|
||||||
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
|
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
|
||||||
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
|
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
|
||||||
function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null; close: () => void; onZoom: (p: Preview) => void }) {
|
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
|
||||||
|
model: ModelEntity | null;
|
||||||
|
close: () => void;
|
||||||
|
onZoom: (p: Preview) => void;
|
||||||
|
onSaved: (model: ModelEntity) => void;
|
||||||
|
onNotify?: (type: "success" | "error", text: string) => void;
|
||||||
|
}) {
|
||||||
const open = !!model;
|
const open = !!model;
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingPortrait, setPendingPortrait] = useState<File | null>(null);
|
||||||
|
const [pendingPortraitUrl, setPendingPortraitUrl] = useState("");
|
||||||
|
const pendingPortraitUrlRef = useRef("");
|
||||||
|
const portraitFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
function clearPendingPortrait() {
|
||||||
|
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
|
||||||
|
pendingPortraitUrlRef.current = "";
|
||||||
|
setPendingPortrait(null);
|
||||||
|
setPendingPortraitUrl("");
|
||||||
|
}
|
||||||
|
function dismiss() {
|
||||||
|
clearPendingPortrait();
|
||||||
|
close();
|
||||||
|
}
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
const { mounted, show } = useOverlayTransition(open, close);
|
const { mounted, show } = useOverlayTransition(open, saving ? undefined : dismiss);
|
||||||
|
useEffect(() => {
|
||||||
|
clearPendingPortrait();
|
||||||
|
if (!model) return;
|
||||||
|
setName(model.name);
|
||||||
|
setDescription(model.description || "");
|
||||||
|
setSaving(false);
|
||||||
|
}, [model?.id]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
|
||||||
|
}, []);
|
||||||
if (!mounted || !model) return null;
|
if (!mounted || !model) return null;
|
||||||
|
const currentModel = model;
|
||||||
|
const portraitUrl = pendingPortraitUrl || model.portrait;
|
||||||
|
const isDirty = Boolean(pendingPortrait) || name.trim() !== model.name || description.trim() !== (model.description || "");
|
||||||
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
|
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
|
||||||
|
async function save() {
|
||||||
|
if (currentModel.is_official || !name.trim() || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let updated: ModelEntity;
|
||||||
|
if (pendingPortrait) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", pendingPortrait);
|
||||||
|
form.append("name", name.trim());
|
||||||
|
form.append("description", description.trim());
|
||||||
|
updated = await api.uploadModelPortrait(currentModel.id, form);
|
||||||
|
} else {
|
||||||
|
updated = await api.updateLibraryModel(currentModel.id, { name: name.trim(), description: description.trim() });
|
||||||
|
}
|
||||||
|
onSaved(updated);
|
||||||
|
clearPendingPortrait();
|
||||||
|
setName(updated.name);
|
||||||
|
setDescription(updated.description || "");
|
||||||
|
onNotify?.("success", "模特资料已保存");
|
||||||
|
} catch (error) {
|
||||||
|
onNotify?.("error", error instanceof Error ? error.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectPortrait(file?: File) {
|
||||||
|
if (!file || currentModel.is_official || saving) return;
|
||||||
|
clearPendingPortrait();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
pendingPortraitUrlRef.current = url;
|
||||||
|
setPendingPortrait(file);
|
||||||
|
setPendingPortraitUrl(url);
|
||||||
|
}
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
|
<div className={`modal-bg${show ? " show" : ""}`} onClick={() => { if (!saving) dismiss(); }}>
|
||||||
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
<div className="modal-h">
|
<div className="modal-h">
|
||||||
<div className="ic-m"><User size={16} /></div>
|
<div className="ic-m"><User size={16} /></div>
|
||||||
<div className="ti">
|
<div className="ti">
|
||||||
{model.name}
|
模特详情
|
||||||
<span>{model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
<span>模特 · {model.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
|
<button className="x modal-x" type="button" disabled={saving} onClick={dismiss} aria-label="关闭"><X size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-b model-detail-b">
|
<div className="modal-b model-detail-b">
|
||||||
|
<div className="model-detail-left">
|
||||||
|
<div className="model-detail-section-h">
|
||||||
|
<strong>形象图</strong>
|
||||||
|
<div className="model-detail-section-actions">
|
||||||
|
<span className="mono">// PORTRAIT</span>
|
||||||
|
{!model.is_official && (
|
||||||
|
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
|
||||||
|
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
|
||||||
<div
|
<div
|
||||||
className={`placeholder model-detail-portrait${model.portrait ? " has-mock-media" : ""}`}
|
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}`}
|
||||||
style={model.portrait ? mediaStyle(model.portrait) : undefined}
|
style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
|
||||||
role={model.portrait ? "button" : undefined}
|
role={portraitUrl ? "button" : undefined}
|
||||||
tabIndex={model.portrait ? 0 : undefined}
|
tabIndex={portraitUrl ? 0 : undefined}
|
||||||
onClick={model.portrait ? () => onZoom({ src: model.portrait, kind: "image", name: model.name }) : undefined}
|
onClick={portraitUrl ? () => onZoom({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
|
||||||
onKeyDown={model.portrait ? zoomKey({ src: model.portrait, kind: "image", name: model.name }) : undefined}
|
onKeyDown={portraitUrl ? zoomKey({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
|
||||||
>
|
>
|
||||||
{!model.portrait && <span className="ph-frame">无图</span>}
|
{!portraitUrl && <span className="ph-frame">无图</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="model-detail-tags mono">
|
<div className="model-detail-tags mono">
|
||||||
{model.is_official && <span className="pill info">官方模板</span>}
|
{model.is_official && <span className="pill info">官方模板</span>}
|
||||||
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
|
||||||
|
{pendingPortrait && <span className="pill info">待保存</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="model-detail-right">
|
||||||
|
<div>
|
||||||
|
<div className="model-detail-section-h">
|
||||||
|
<strong>三视图</strong>
|
||||||
|
<div className="model-detail-section-actions">
|
||||||
|
<span className="mono">// 可选资产</span>
|
||||||
|
{!model.is_official && (
|
||||||
|
<button className="btn btn-sm" type="button" disabled title={model.portrait ? "真实生成与计费将在下一步接通" : "请先设置模特形象图"}>
|
||||||
|
<RefreshCw size={13} />{model.triview ? "重新生成 · 20 积分" : "生成三视图 · 20 积分"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{model.description && <p className="model-detail-desc">{model.description}</p>}
|
|
||||||
<div className="model-detail-label mono">// 三视图</div>
|
|
||||||
<div
|
<div
|
||||||
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
|
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
|
||||||
style={model.triview ? mediaStyle(model.triview) : undefined}
|
style={model.triview ? mediaStyle(model.triview) : undefined}
|
||||||
@@ -63,9 +158,27 @@ function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null;
|
|||||||
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
||||||
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
|
||||||
>
|
>
|
||||||
{!model.triview && <span className="ph-frame mono">// 无三视图</span>}
|
{!model.triview && <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="model-detail-form">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="model-edit-name">模特名称</label>
|
||||||
|
<input id="model-edit-name" className="input" maxLength={255} disabled={model.is_official || saving} value={name} onChange={(event) => setName(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label" htmlFor="model-edit-description">模特描述 / 提示词</label>
|
||||||
|
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field-hint mono">// 编辑模特资料不会回写已存在的视频项目角色</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-f">
|
||||||
|
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
|
||||||
|
<button className="btn" type="button" disabled={saving} onClick={dismiss}>关闭</button>
|
||||||
|
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -203,7 +316,16 @@ export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error"
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ModelDetailModal model={detail} close={() => setDetail(null)} onZoom={setPreview} />
|
<ModelDetailModal
|
||||||
|
model={detail}
|
||||||
|
close={() => setDetail(null)}
|
||||||
|
onZoom={setPreview}
|
||||||
|
onSaved={(updated) => {
|
||||||
|
setItems((list) => list.map((item) => item.id === updated.id ? updated : item));
|
||||||
|
setDetail(updated);
|
||||||
|
}}
|
||||||
|
onNotify={onNotify}
|
||||||
|
/>
|
||||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Notice, Page } from "./route-config";
|
|||||||
import { money, stageOrder, statusPill } from "./stage-config";
|
import { money, stageOrder, statusPill } from "./stage-config";
|
||||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||||
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
||||||
import { ActorLibrary } from "../components/actor-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import { IconKitSvg } from "../components/IconKitSvg";
|
import { IconKitSvg } from "../components/IconKitSvg";
|
||||||
|
|
||||||
@@ -472,16 +472,16 @@ export function PipelinePage(props: {
|
|||||||
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||||
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
|
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
|
||||||
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
|
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
|
||||||
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
|
// 流程步骤4 · 模特库:用现有模特替换角色参考图 / AI 生成模特 / 本地上传模特
|
||||||
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
|
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
|
||||||
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
|
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
|
||||||
onGenerateActor: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
onGenerateModel: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||||
// 本地上传演员:返回上传后的 Asset(供添加人物工作台进右侧栏)
|
// 本地上传模特:返回形象 Asset,供添加模特工作台保存。
|
||||||
onUploadActor: (file: File) => Promise<Asset | null>;
|
onUploadModel: (file: File) => Promise<Asset | null>;
|
||||||
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
|
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
|
||||||
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
||||||
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
|
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
|
||||||
onRenameActor?: (assetId: string, name: string) => Promise<unknown>;
|
onRenameModel?: (assetId: string, name: string) => Promise<unknown>;
|
||||||
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
|
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
|
||||||
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
|
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
|
||||||
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
|
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
|
||||||
@@ -502,7 +502,7 @@ export function PipelinePage(props: {
|
|||||||
const {
|
const {
|
||||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onNotify,
|
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onNotify,
|
||||||
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
|
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
|
||||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
} = props;
|
} = props;
|
||||||
@@ -636,8 +636,8 @@ export function PipelinePage(props: {
|
|||||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||||
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||||
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
|
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
|
||||||
// 人物不走这里——人物有专门的演员库(ActorLibrary,含模特库/三视图工作台),对人物是对的。
|
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
|
||||||
// 旧 bug:场景卡「替换」误用了演员库 → 列的是角色数据。现在场景改走本选图层。
|
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。
|
||||||
type AssetPick = {
|
type AssetPick = {
|
||||||
title: string;
|
title: string;
|
||||||
category: "product_image" | "scene";
|
category: "product_image" | "scene";
|
||||||
@@ -784,12 +784,9 @@ export function PipelinePage(props: {
|
|||||||
delBusy(busyKey);
|
delBusy(busyKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图
|
// 流程步骤4 · 生成人物立绘;三视图只在角色详情里手动生成。
|
||||||
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
|
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
|
||||||
const g = await genBaseAsset("person", prompt, label, busyKey);
|
return await genBaseAsset("person", prompt, label, busyKey);
|
||||||
const newAsset = g?.adopted_asset;
|
|
||||||
if (newAsset) await genTriview(newAsset, `${busyKey}:tri`);
|
|
||||||
return g;
|
|
||||||
}
|
}
|
||||||
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
|
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
|
||||||
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
|
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
|
||||||
@@ -799,13 +796,13 @@ export function PipelinePage(props: {
|
|||||||
const extractPollRef = useRef(0); // 提取轮询定时器句柄
|
const extractPollRef = useRef(0); // 提取轮询定时器句柄
|
||||||
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
|
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
|
||||||
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
|
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
|
||||||
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/三视图/场景图)
|
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
|
||||||
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
|
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
|
||||||
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
|
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
|
||||||
for (const e of entities) {
|
for (const e of entities) {
|
||||||
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
|
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
|
||||||
if (e.type === "character") {
|
if (e.type === "character") {
|
||||||
if (mode === "full") await genPersonWithTri(e.visual_prompt, e.name, bk); // 立绘 + 三视图
|
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
|
||||||
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
|
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
|
||||||
} else {
|
} else {
|
||||||
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
|
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
|
||||||
@@ -843,7 +840,7 @@ export function PipelinePage(props: {
|
|||||||
extractPollRef.current = window.setInterval(() => { void tick(); }, 3000);
|
extractPollRef.current = window.setInterval(() => { void tick(); }, 3000);
|
||||||
void tick();
|
void tick();
|
||||||
}
|
}
|
||||||
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+全套(立绘+三视图+场景图)
|
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+生成角色场景(三视图手动生成)
|
||||||
async function runExtract(mode: "only" | "gen" | "full") {
|
async function runExtract(mode: "only" | "gen" | "full") {
|
||||||
if (extractState === "running") return;
|
if (extractState === "running") return;
|
||||||
setExtractErr("");
|
setExtractErr("");
|
||||||
@@ -945,12 +942,30 @@ export function PipelinePage(props: {
|
|||||||
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 asset id(组内候选)
|
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 asset id(组内候选)
|
||||||
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 asset id
|
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 asset id
|
||||||
const [adPrompt, setAdPrompt] = useState("");
|
const [adPrompt, setAdPrompt] = useState("");
|
||||||
|
// 角色立绘升级为模特的状态按「形象资产 id」记录;后端精确查询,不能用全量列表猜测。
|
||||||
|
const [enrolledModelAssets, setEnrolledModelAssets] = useState<Set<string>>(() => new Set());
|
||||||
|
const [modelEnrollmentLoading, setModelEnrollmentLoading] = useState(false);
|
||||||
|
const [modelEnrollmentBusy, setModelEnrollmentBusy] = useState(false);
|
||||||
|
async function loadModelEnrollment(assetId: string | null | undefined) {
|
||||||
|
if (!assetId) return;
|
||||||
|
setModelEnrollmentLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.listModels({ portraitAsset: assetId, pageSize: 1 });
|
||||||
|
if (result.results.length) setEnrolledModelAssets((known) => new Set(known).add(assetId));
|
||||||
|
} catch {
|
||||||
|
// 查询失败时保留可升级入口;提交接口本身仍是幂等的,不会重复建模特。
|
||||||
|
} finally {
|
||||||
|
setModelEnrollmentLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
useBodyScrollLock(Boolean(adDetail));
|
useBodyScrollLock(Boolean(adDetail));
|
||||||
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
|
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
|
||||||
setAdDetail({ kind, key: entity.key });
|
setAdDetail({ kind, key: entity.key });
|
||||||
setAdPortraitId(entity.group.adopted_asset ?? entity.versions.at(-1) ?? null);
|
const portraitAssetId = entity.group.adopted_asset ?? entity.versions.at(-1) ?? null;
|
||||||
|
setAdPortraitId(portraitAssetId);
|
||||||
setAdTriId(null); // 跟随当前立绘的最新三视图
|
setAdTriId(null); // 跟随当前立绘的最新三视图
|
||||||
setAdPrompt(entity.group.prompt ?? "");
|
setAdPrompt(entity.group.prompt ?? "");
|
||||||
|
if (kind === "person") void loadModelEnrollment(portraitAssetId);
|
||||||
}
|
}
|
||||||
// seed(未生成)卡也能点进详情:还没组,用脚本名(=key)+ 当前提示词开一个「空壳」详情。
|
// seed(未生成)卡也能点进详情:还没组,用脚本名(=key)+ 当前提示词开一个「空壳」详情。
|
||||||
// 进去先生成立绘;三视图按既有逻辑(无立绘则禁用)天然挡住。生成完刷新 → 同 key(=名字)自动切到真实体。
|
// 进去先生成立绘;三视图按既有逻辑(无立绘则禁用)天然挡住。生成完刷新 → 同 key(=名字)自动切到真实体。
|
||||||
@@ -960,31 +975,30 @@ export function PipelinePage(props: {
|
|||||||
setAdTriId(null);
|
setAdTriId(null);
|
||||||
setAdPrompt(promptValue);
|
setAdPrompt(promptValue);
|
||||||
}
|
}
|
||||||
// 流程步骤4 · 演员库覆盖层:replace 带要替换的目标(已生成卡=groupId;seed 占位卡还没组=kind+label);
|
// 模特库覆盖层:replace 带替换目标;browse + studio 直接进“添加模特”。
|
||||||
// browse 带 studio 直接进「添加人物工作台」
|
const [modelLib, setModelLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
|
||||||
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
|
function openModelReplace(entity: AssetEntity) {
|
||||||
function openActorReplace(entity: AssetEntity) {
|
setModelLib({ mode: "replace", groupId: entity.group.id });
|
||||||
setActorLib({ mode: "replace", groupId: entity.group.id });
|
|
||||||
}
|
}
|
||||||
// seed 占位卡「替换」:还没 group,记 kind+label,选演员后由后端按 label 命中/建组再挂
|
// seed 占位卡“替换”:还没组,选模特后由后端按 label 命中/建组再挂。
|
||||||
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
|
function openModelReplaceSeed(kind: "person" | "scene", tag: string) {
|
||||||
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
|
setModelLib({ mode: "replace", seedKind: kind, seedLabel: tag });
|
||||||
}
|
}
|
||||||
// 场景「替换」:走通用选图层(取团队场景图),而非人物专用的演员库(修复:场景替换误列角色数据)。
|
// 场景“替换”走通用选图层(取团队场景图),不使用模特库。
|
||||||
function openSceneReplaceSeed(tag: string) {
|
function openSceneReplaceSeed(tag: string) {
|
||||||
setAssetPick({ title: "选择场景素材", category: "scene", target: { kind: "scene", label: tag }, uploadCategory: "scene", uploadName: `${tag || "场景"}·场景图` });
|
setAssetPick({ title: "选择场景素材", category: "scene", target: { kind: "scene", label: tag }, uploadCategory: "scene", uploadName: `${tag || "场景"}·场景图` });
|
||||||
}
|
}
|
||||||
function openSceneReplaceEntity(groupId: string, label?: string) {
|
function openSceneReplaceEntity(groupId: string, label?: string) {
|
||||||
setAssetPick({ title: "选择场景素材", category: "scene", target: { group_id: groupId }, uploadCategory: "scene", uploadName: `${label || "场景"}·场景图` });
|
setAssetPick({ title: "选择场景素材", category: "scene", target: { group_id: groupId }, uploadCategory: "scene", uploadName: `${label || "场景"}·场景图` });
|
||||||
}
|
}
|
||||||
async function pickActor(assetId: string, assetName?: string) {
|
async function pickModel(assetId: string, assetName?: string) {
|
||||||
if (actorLib?.mode === "replace") {
|
if (modelLib?.mode === "replace") {
|
||||||
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
|
if (modelLib.groupId) await onAttachBaseAsset({ group_id: modelLib.groupId }, assetId);
|
||||||
// 新增人物(无既有 group / 无脚本标签):用所选演员名当 label 建一个新人物组挂上
|
// 新增角色(无既有 group / 无脚本标签):用所选模特名建立角色组并挂上形象资产。
|
||||||
else if (actorLib.addNew && actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: (assetName || "").trim() }, assetId);
|
else if (modelLib.addNew && modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: (assetName || "").trim() }, assetId);
|
||||||
else if (actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: actorLib.seedLabel }, assetId);
|
else if (modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: modelLib.seedLabel }, assetId);
|
||||||
}
|
}
|
||||||
setActorLib(null);
|
setModelLib(null);
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adDetail) return;
|
if (!adDetail) return;
|
||||||
@@ -2441,7 +2455,7 @@ export function PipelinePage(props: {
|
|||||||
const productName = productRecord?.title || "透真补水面膜";
|
const productName = productRecord?.title || "透真补水面膜";
|
||||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||||
|
|
||||||
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonWithTri/genBaseAsset 出图、直接扣用户费,
|
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
|
||||||
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
||||||
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
||||||
|
|
||||||
@@ -3065,8 +3079,8 @@ export function PipelinePage(props: {
|
|||||||
<div className="sec-h">
|
<div className="sec-h">
|
||||||
<h3>{KIND_LABEL[kind]} · {entities.length} 个</h3>
|
<h3>{KIND_LABEL[kind]} · {entities.length} 个</h3>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
|
{/* 对齐设计稿:角色从模特库选择,场景直接生成一版。 */}
|
||||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setModelLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-grid-2">
|
<div className="asset-grid-2">
|
||||||
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
|
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
|
||||||
@@ -3133,7 +3147,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack"><strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
<div className="hstack"><strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
||||||
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
|
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
|
||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openActorReplaceSeed(kind, tag)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openModelReplaceSeed(kind, tag)}>替换</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3196,7 +3210,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>{busy ? "生成中…" : "重跑"}</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openActorReplace(entity)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openModelReplace(entity)}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4098,7 +4112,8 @@ export function PipelinePage(props: {
|
|||||||
<div className="ad-thumbs">
|
<div className="ad-thumbs">
|
||||||
{portraitVersions.map((assetId) => {
|
{portraitVersions.map((assetId) => {
|
||||||
const u = candUrl(grp, assetId);
|
const u = candUrl(grp, assetId);
|
||||||
return <div className={`thumb${assetId === viewPortraitAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title="切换立绘版本(三视图跟着切)" style={u ? mediaStyle(u) : undefined} onClick={() => { setAdPortraitId(assetId); setAdTriId(null); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdPortraitId(assetId); setAdTriId(null); } }} />;
|
const selectPortrait = () => { setAdPortraitId(assetId); setAdTriId(null); if (isPerson) void loadModelEnrollment(assetId); };
|
||||||
|
return <div className={`thumb${assetId === viewPortraitAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title="切换立绘版本(三视图跟着切)" style={u ? mediaStyle(u) : undefined} onClick={selectPortrait} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPortrait(); } }} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4160,7 +4175,7 @@ export function PipelinePage(props: {
|
|||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => {
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => {
|
||||||
if (adDetail.kind === "scene") { isSeed ? openSceneReplaceSeed(entity.name) : openSceneReplaceEntity(entity.group.id, entity.name); }
|
if (adDetail.kind === "scene") { isSeed ? openSceneReplaceSeed(entity.name) : openSceneReplaceEntity(entity.group.id, entity.name); }
|
||||||
else { isSeed ? openActorReplaceSeed(adDetail.kind, entity.name) : openActorReplace(entity); }
|
else { isSeed ? openModelReplaceSeed(adDetail.kind, entity.name) : openModelReplace(entity); }
|
||||||
}}>替换</button>
|
}}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4171,6 +4186,25 @@ export function PipelinePage(props: {
|
|||||||
<div className="ad-foot-stats">
|
<div className="ad-foot-stats">
|
||||||
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>下载立绘</button>}
|
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>下载立绘</button>}
|
||||||
</div>
|
</div>
|
||||||
|
{isPerson && <button
|
||||||
|
className="btn"
|
||||||
|
type="button"
|
||||||
|
disabled={loading || modelEnrollmentLoading || modelEnrollmentBusy || !viewPortraitAsset || enrolledModelAssets.has(viewPortraitAsset)}
|
||||||
|
title={enrolledModelAssets.has(viewPortraitAsset) ? "无需重复升级" : undefined}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!viewPortraitAsset) return;
|
||||||
|
setModelEnrollmentBusy(true);
|
||||||
|
try {
|
||||||
|
await api.enrollModelFromAsset(viewPortraitAsset, entity.name);
|
||||||
|
setEnrolledModelAssets((known) => new Set(known).add(viewPortraitAsset));
|
||||||
|
onNotify?.("success", "已升级为模特,可在模特库中复用");
|
||||||
|
} catch (error) {
|
||||||
|
onNotify?.("error", error instanceof Error && error.message ? error.message : "升级为模特失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setModelEnrollmentBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{modelEnrollmentBusy ? "升级中…" : modelEnrollmentLoading ? "检查中…" : enrolledModelAssets.has(viewPortraitAsset) ? "该角色形象已在模特库" : "升级为模特"}</button>}
|
||||||
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
|
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
|
||||||
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
|
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
|
||||||
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
|
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
|
||||||
@@ -4234,18 +4268,16 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
|
{/* 流程步骤4 · 模特库:浏览 / 添加模特(AI 生成·本地上传)/ 替换回填 */}
|
||||||
<ActorLibrary
|
<ModelLibrary
|
||||||
open={Boolean(actorLib)}
|
open={Boolean(modelLib)}
|
||||||
mode={actorLib?.mode || "browse"}
|
mode={modelLib?.mode || "browse"}
|
||||||
initialStudio={actorLib?.studio}
|
initialStudio={modelLib?.studio}
|
||||||
assets={assets}
|
onClose={() => setModelLib(null)}
|
||||||
onClose={() => setActorLib(null)}
|
onPick={pickModel}
|
||||||
onPick={pickActor}
|
onGenerate={onGenerateModel}
|
||||||
onGenerate={onGenerateActor}
|
onUpload={onUploadModel}
|
||||||
onUpload={onUploadActor}
|
onRename={onRenameModel}
|
||||||
onGenerateTriview={onGenerateTriview}
|
|
||||||
onRename={onRenameActor}
|
|
||||||
onRefresh={() => void onRefreshProject()}
|
onRefresh={() => void onRefreshProject()}
|
||||||
/>
|
/>
|
||||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export type Asset = {
|
|||||||
origin_task?: string | null;
|
origin_task?: string | null;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
purged_at?: string | null;
|
purged_at?: string | null;
|
||||||
// 是否在「资产库」列表展示:图片生成的成图自动 true(R109);演员立绘暂存 false,「保存人物」后置 true
|
// 是否在「资产库」列表展示:图片生成成图自动 true(R109);模特候选/项目角色资料不进入资产库
|
||||||
in_library?: boolean;
|
in_library?: boolean;
|
||||||
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
||||||
product?: string | null;
|
product?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user