refactor(core): 基础资产改对逻辑+对齐设计稿(一组多版本候选 + 详情弹窗 asset-detail-grid)
逻辑(后端):用 BaseAssetGroup 本来的设计,不再每次生成新建组 - generate_base_asset 复用同实体的组(商品=唯一商品组;人物/场景按 label):追加 candidate + 采用最新 - 版本 = candidate_assets,采用/当前 = adopted_asset;采用/切换走真 adopt-base-asset(下游只认 adopted) - 人物三视图绑定到「某一版立绘 asset」:metadata.triview_of=<立绘 asset id>,同立绘多次=同组追加候选 - generate-triview 端点改取 portrait_asset_id;attach-base-asset 坏参数 404 加固 设计(前端): - 人物/场景卡对齐设计稿(.prompt-box 可编辑 + 重跑/替换 + 转圈态,区头 N 个 + 克制「+ 新增」) - 详情弹窗整套换成设计稿 asset-detail-grid:左大立绘(3:4)+版本缩略条;右三视图 section(16:9 + 重跑/下载 + 单张大图 + 暂无 tip + v1/v2 版本条)+ 提示词 section;脚部 下载立绘 + 使用该资产;场景无三视图 - 商品三视图内联面板版本=candidate_assets、采用走真 adopt - 修:.thumb/.v-thumb/.h-thumb 非 .placeholder 导致缩略图不显图 → 接 --mock-media-url 本地 playwright 截图实测:基础资产卡 + 详情弹窗均按设计稿渲染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -561,7 +561,22 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "") -> BaseAssetGroup:
|
def _find_entity_group(project, kind: str, label: str, group_id: str | None):
|
||||||
|
"""复用同一实体的基础资产组(版本=candidate_assets,采用=adopted_asset)。
|
||||||
|
优先 group_id;商品=该项目唯一商品组;人物/场景按 label 命中非三视图组;都没有则 None(新建)。"""
|
||||||
|
if group_id:
|
||||||
|
return project.base_asset_groups.filter(id=group_id).first()
|
||||||
|
candidates = list(project.base_asset_groups.filter(kind=kind).order_by("created_at"))
|
||||||
|
candidates = [g for g in candidates if not (g.metadata or {}).get("triview_of")] # 三视图组不算实体本体
|
||||||
|
if kind == BaseAssetGroup.Kind.PRODUCT:
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
lbl = (label or "").strip()
|
||||||
|
if lbl:
|
||||||
|
return next((g for g in candidates if (g.metadata or {}).get("label") == lbl), None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> BaseAssetGroup:
|
||||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise ValueError("no active image model configured")
|
raise ValueError("no active image model configured")
|
||||||
@@ -604,12 +619,16 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
category=category,
|
category=category,
|
||||||
asset_type=Asset.Type.IMAGE,
|
asset_type=Asset.Type.IMAGE,
|
||||||
)
|
)
|
||||||
# label = 该资产对应的脚本提取标签(人物/场景名),用于把生成结果归回对应的标签卡
|
# 复用同实体的组:追加候选 + 采用最新(版本=candidate_assets,采用=adopted_asset);无则新建
|
||||||
group_meta = {"label": label.strip()} if label and label.strip() else {}
|
group = _find_entity_group(project, kind, label, group_id)
|
||||||
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta)
|
if group is None:
|
||||||
|
group_meta = {"label": label.strip()} if label and label.strip() else {}
|
||||||
|
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta)
|
||||||
|
elif prompt:
|
||||||
|
group.prompt = prompt
|
||||||
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", "prompt", "updated_at"])
|
||||||
# 真人资产:事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务)
|
# 真人资产:事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务)
|
||||||
if kind == BaseAssetGroup.Kind.PERSON:
|
if kind == BaseAssetGroup.Kind.PERSON:
|
||||||
from apps.assets.review import submit_asset_for_review
|
from apps.assets.review import submit_asset_for_review
|
||||||
@@ -625,16 +644,14 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup":
|
def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup":
|
||||||
"""流程步骤4 · 据「某一版立绘」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。
|
"""流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。
|
||||||
三视图与立绘 1:1 绑定:新立绘 → 新三视图。落一个 metadata.triview_of=<立绘组id> 的 person 组。"""
|
三视图与立绘 1:1 绑定:metadata.triview_of=<立绘 asset id>;同一立绘多次=同组追加候选(版本)。"""
|
||||||
from apps.ai.model_library import THREE_VIEW_PROMPT
|
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||||||
|
|
||||||
if portrait_group.kind != BaseAssetGroup.Kind.PERSON:
|
|
||||||
raise ValueError("三视图仅用于人物立绘")
|
|
||||||
portrait_asset = portrait_group.adopted_asset
|
|
||||||
if portrait_asset is None:
|
if portrait_asset is None:
|
||||||
raise ValueError("该立绘尚未生成,无法据它生成三视图")
|
raise ValueError("该立绘尚未生成,无法据它生成三视图")
|
||||||
|
asset_key = str(portrait_asset.id)
|
||||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise ValueError("no active image model configured")
|
raise ValueError("no active image model configured")
|
||||||
@@ -642,7 +659,7 @@ def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup
|
|||||||
if not hasattr(provider, "image_edit"):
|
if not hasattr(provider, "image_edit"):
|
||||||
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
||||||
ref_url = _asset_preview_url(portrait_asset)
|
ref_url = _asset_preview_url(portrait_asset)
|
||||||
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": str(portrait_group.id)}
|
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key}
|
||||||
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
|
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
|
||||||
reservation = task.credit_reservation
|
reservation = task.credit_reservation
|
||||||
try:
|
try:
|
||||||
@@ -659,11 +676,14 @@ def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup
|
|||||||
team=project.team, user=user, project=project, task=task, media=media,
|
team=project.team, user=user, project=project, task=task, media=media,
|
||||||
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
|
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
|
||||||
)
|
)
|
||||||
# label 带立绘组 id:前端据此把三视图归到对应立绘版本(切立绘 → 切三视图)
|
# 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新
|
||||||
group = BaseAssetGroup.objects.create(
|
group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at")
|
||||||
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT,
|
if (g.metadata or {}).get("triview_of") == asset_key), None)
|
||||||
metadata={"label": f"{portrait_group.id} ·三视图", "triview_of": str(portrait_group.id)},
|
if group is None:
|
||||||
)
|
group = BaseAssetGroup.objects.create(
|
||||||
|
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT,
|
||||||
|
metadata={"label": "·三视图", "triview_of": asset_key},
|
||||||
|
)
|
||||||
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"])
|
||||||
|
|||||||
@@ -275,13 +275,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="generate-triview")
|
@action(detail=True, methods=["post"], url_path="generate-triview")
|
||||||
def generate_triview(self, request, pk=None):
|
def generate_triview(self, request, pk=None):
|
||||||
"""流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性)。"""
|
"""流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,三视图绑定该立绘)。"""
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
portrait = BaseAssetGroup.objects.filter(project=project, id=request.data.get("portrait_group_id")).first()
|
portrait_asset = Asset.objects.filter(team=project.team, id=request.data.get("portrait_asset_id")).first()
|
||||||
if portrait is None:
|
if portrait_asset is None:
|
||||||
return Response({"detail": "portrait group not found"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": "portrait asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
try:
|
try:
|
||||||
group = generate_person_triview(project=project, user=request.user, portrait_group=portrait)
|
group = generate_person_triview(project=project, user=request.user, portrait_asset=portrait_asset)
|
||||||
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)
|
||||||
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
|
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||||
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")}
|
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")}
|
||||||
onGenerateTriview={(portraitGroupId) => action(() => api.generateTriview(pipelineProject.id, { portrait_group_id: portraitGroupId }), "三视图已据立绘生成")}
|
onGenerateTriview={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")}
|
||||||
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
||||||
onUploadActor={(file) => {
|
onUploadActor={(file) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|||||||
@@ -308,8 +308,8 @@ export const api = {
|
|||||||
attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: 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) });
|
||||||
},
|
},
|
||||||
// 流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘组)
|
// 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset)
|
||||||
generateTriview(projectId: string, payload: { portrait_group_id: string }) {
|
generateTriview(projectId: string, payload: { portrait_asset_id: string }) {
|
||||||
return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
|
return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
||||||
|
|||||||
@@ -268,6 +268,22 @@
|
|||||||
.prod-preview-img { aspect-ratio: 16/9; }
|
.prod-preview-img { aspect-ratio: 16/9; }
|
||||||
.prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }
|
.prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }
|
||||||
.prod-preview-history { display: none; flex-direction: column; gap: 6px; }
|
.prod-preview-history { display: none; flex-direction: column; gap: 6px; }
|
||||||
|
.prod-preview-history.show { display: flex; }
|
||||||
|
.prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }
|
||||||
|
.prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; }
|
||||||
|
.prod-preview-history .h-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; transition: border-color var(--t-base); display: grid; place-items: center; overflow: hidden; }
|
||||||
|
.prod-preview-history .h-thumb:hover { border-color: var(--heat-40); }
|
||||||
|
.prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
|
||||||
|
.prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; }
|
||||||
|
.prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; position: relative; z-index: 1; }
|
||||||
|
.prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; }
|
||||||
|
.prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; }
|
||||||
|
.prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 9px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; z-index: 2; }
|
||||||
|
.prod-preview-history .h-thumb.adopted .badge { display: block; }
|
||||||
|
.prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); }
|
||||||
|
.prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }
|
||||||
|
.prod-preview-adopt:disabled, .prod-preview-adopt:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); cursor: not-allowed; opacity: 1; }
|
||||||
|
|
||||||
.asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }
|
.asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
.asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
.asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||||
@@ -276,6 +292,12 @@
|
|||||||
.asset-card-2.asset-seed .thumb-2 { opacity: .7; }
|
.asset-card-2.asset-seed .thumb-2 { opacity: .7; }
|
||||||
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
|
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
|
||||||
.asset-card-2 .body-2 { padding: 12px 14px; }
|
.asset-card-2 .body-2 { padding: 12px 14px; }
|
||||||
|
/* 对齐设计稿:人物/场景卡提示词框(mono 可编辑,非 textarea) */
|
||||||
|
.prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }
|
||||||
|
.prompt-box[contenteditable="true"] { cursor: text; outline: none; }
|
||||||
|
.prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); }
|
||||||
|
.prompt-box[contenteditable="true"]:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }
|
||||||
|
.asset-card-2 .thumb-2 .spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; }
|
||||||
.asset-card-2 .body-2 .btn:disabled, .asset-card-2 .body-2 .btn.disabled { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; cursor: not-allowed; opacity: .72; transform: none; }
|
.asset-card-2 .body-2 .btn:disabled, .asset-card-2 .body-2 .btn.disabled { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; cursor: not-allowed; opacity: .72; transform: none; }
|
||||||
.asset-card-2 .body-2 .btn:disabled:hover, .asset-card-2 .body-2 .btn.disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; transform: none; }
|
.asset-card-2 .body-2 .btn:disabled:hover, .asset-card-2 .body-2 .btn.disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; transform: none; }
|
||||||
|
|
||||||
@@ -572,6 +594,51 @@
|
|||||||
.tri-cand-card .tri-cand-img { aspect-ratio: 16/9; }
|
.tri-cand-card .tri-cand-img { aspect-ratio: 16/9; }
|
||||||
.tri-cand-card .tri-cand-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; }
|
.tri-cand-card .tri-cand-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; }
|
||||||
.tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
|
.tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
|
||||||
|
/* ── 流程步骤4 · 人物/场景详情弹窗(对齐设计稿 asset-detail-grid)── */
|
||||||
|
.asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||||
|
.asset-detail-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
|
||||||
|
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.asset-detail-lead .ad-lead-wrap { position: relative; }
|
||||||
|
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); }
|
||||||
|
.ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; }
|
||||||
|
.ad-zoom-btn:hover { background: rgba(21,20,15,.92); }
|
||||||
|
.ad-zoom-btn svg { width: 14px; height: 14px; }
|
||||||
|
.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn, .asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; }
|
||||||
|
.asset-detail-tri-row .ad-tri-wrap { position: relative; }
|
||||||
|
.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.asset-detail-lead .ad-thumbs .thumb { flex: 0 0 56px; width: 56px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; transition: border-color var(--t-base); }
|
||||||
|
.asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }
|
||||||
|
.asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }
|
||||||
|
.asset-detail-right .ad-section + .ad-section { margin-top: 18px; }
|
||||||
|
.asset-detail-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
|
||||||
|
.asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; display: grid; place-items: center; }
|
||||||
|
.asset-detail-section-h .ic svg { width: 14px; height: 14px; }
|
||||||
|
.asset-detail-section-h .ad-ratio-chip { margin-left: auto; font-family: var(--font-mono); font-size: 12px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); border: 1px solid var(--border-faint); color: var(--black-alpha-56); letter-spacing: .02em; }
|
||||||
|
.asset-detail-section-h .ad-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
|
.asset-detail-section-h .ad-icon-btn:hover { color: var(--heat); border-color: var(--heat-40); }
|
||||||
|
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
|
||||||
|
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
|
||||||
|
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }
|
||||||
|
.asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; gap: 8px; }
|
||||||
|
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
|
||||||
|
.md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }
|
||||||
|
.md-view-versions .v-thumb:hover { border-color: var(--heat-40); }
|
||||||
|
.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
|
||||||
|
.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
|
||||||
|
.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }
|
||||||
|
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }
|
||||||
|
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
|
||||||
|
.asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12px; cursor: pointer; font-family: inherit; flex-shrink: 0; }
|
||||||
|
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
|
||||||
|
.asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
|
.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); }
|
||||||
|
.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }
|
||||||
|
.ad-detail-prompt { width: 100%; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; outline: none; resize: vertical; min-height: 64px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
|
||||||
|
/* 版本缩略图(非 .placeholder,需自己接 --mock-media-url 才显图) */
|
||||||
|
.ad-thumbs .thumb.has-mock-media, .md-view-versions .v-thumb.has-mock-media, .prod-preview-history .h-thumb.has-mock-media { background-image: var(--mock-media-url); background-size: cover; background-position: center; background-repeat: no-repeat; }
|
||||||
|
.ad-detail-prompt:hover { border-color: var(--heat-20); }
|
||||||
|
.ad-detail-prompt:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }
|
||||||
|
|
||||||
.tri-empty { text-align: center; padding: 8px 0; }
|
.tri-empty { text-align: center; padding: 8px 0; }
|
||||||
.tri-empty .tri-empty-hint { font-size: 13px; line-height: 1.6; color: var(--black-alpha-56); margin-top: 14px; }
|
.tri-empty .tri-empty-hint { font-size: 13px; line-height: 1.6; color: var(--black-alpha-56); margin-top: 14px; }
|
||||||
|
|
||||||
|
|||||||
@@ -488,10 +488,10 @@ export function PipelinePage(props: {
|
|||||||
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
|
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
|
||||||
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
|
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
|
||||||
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
|
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
|
||||||
// 行39 · AI 生成三视图弹窗:记录每次生成的三视图版本(每次生成=一个 product group),可切换查看 + 重跑 + 采用
|
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
|
||||||
const [triViewOpen, setTriViewOpen] = useState(false);
|
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||||
const [triSelId, setTriSelId] = useState<string | null>(null); // 弹窗里当前选中查看/采用的三视图版本(product group id)
|
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||||
useBodyScrollLock(triViewOpen);
|
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||||
setAssetTab(kind);
|
setAssetTab(kind);
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
@@ -499,58 +499,55 @@ export function PipelinePage(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 流程步骤4 · 人物/场景「实体」聚合(前端优先):后端每次生成新建一个 group,
|
// ── 流程步骤4 · 正确模型:一个实体 = 一个 BaseAssetGroup,candidate_assets = 各版本,adopted_asset = 采用/当前 ──
|
||||||
// 这里把同一脚本标签(metadata.label)的多个 group 合成「一个角色的多版本立绘」。
|
// 人物立绘版本 = 人物组的 candidate_assets;商品三视图版本 = 商品组的 candidate_assets。
|
||||||
// 三视图与「某一版立绘」1:1 绑定:三视图组带 metadata.triview_of=<立绘组id>,从主网格过滤掉。
|
// 人物「某一版立绘的三视图」= 另一个 person 组,metadata.triview_of = <该立绘 asset id>,其 candidate_assets = 三视图版本。
|
||||||
type BaseGroup = (typeof groups)[number];
|
type BaseGroup = (typeof groups)[number];
|
||||||
const triviewOf = (g?: BaseGroup | null): string => (g?.metadata?.triview_of as string) || "";
|
const triviewOf = (g?: BaseGroup | null): string => (g?.metadata?.triview_of as string) || "";
|
||||||
const isTriview = (g: BaseGroup) => Boolean(triviewOf(g));
|
const isTriview = (g: BaseGroup) => Boolean(triviewOf(g));
|
||||||
// 某一版立绘(portraitGroupId)配套的三视图版本(按 created_at 升序,新的在后)
|
// 某一版立绘资产(assetId)配套的三视图组(同一立绘只一组,内含多版本候选)
|
||||||
function triviewsFor(portraitGroupId: string): BaseGroup[] {
|
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
|
||||||
return groupsByKind("person").filter((g) => triviewOf(g) === portraitGroupId);
|
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
|
||||||
}
|
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
|
||||||
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; portraits: BaseGroup[] };
|
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
|
||||||
// 聚合一个 kind 下的实体列表(立绘按 label 合并版本;无 label 的各自独立成卡;三视图组不计入)
|
|
||||||
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
|
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
|
||||||
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
|
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
|
||||||
const map = new Map<string, { key: string; label: string; portraits: BaseGroup[] }>();
|
const byLabel = new Map<string, BaseGroup>();
|
||||||
for (const g of portraits) {
|
for (const g of portraits) byLabel.set((g.metadata?.label || "").trim() || g.id, g); // 升序遍历,后者(更新)覆盖
|
||||||
|
return [...byLabel.entries()].map(([key, g], i) => {
|
||||||
const label = (g.metadata?.label || "").trim();
|
const label = (g.metadata?.label || "").trim();
|
||||||
const key = label || g.id;
|
const name = label || assetName(g.adopted_asset) || `${KIND_LABEL[kind]} ${i + 1}`;
|
||||||
if (!map.has(key)) map.set(key, { key, label, portraits: [] });
|
return { key, label, name, kind, group: g, versions: g.candidate_assets ?? [] };
|
||||||
map.get(key)!.portraits.push(g);
|
|
||||||
}
|
|
||||||
return [...map.values()].map((e, i) => {
|
|
||||||
const name = e.label || assetName(e.portraits.at(-1)?.adopted_asset) || `${KIND_LABEL[kind]} ${i + 1}`;
|
|
||||||
return { key: e.key, label: e.label, name, kind, portraits: e.portraits };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行38/流程步骤4 · 单卡生成 loading:action() 全局串行,这里只记「当前哪张卡在生成」做局部转圈
|
// 行38/流程步骤4 · 单卡生成 loading:action() 全局串行,这里只记「当前哪张卡在生成」做局部转圈
|
||||||
const [genBusyKey, setGenBusyKey] = useState<string | null>(null);
|
const [genBusyKey, setGenBusyKey] = useState<string | null>(null);
|
||||||
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise<{ id?: string } | null> {
|
type GenResult = { id?: string; adopted_asset?: string | null } | null;
|
||||||
|
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise<GenResult> {
|
||||||
if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心)
|
if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心)
|
||||||
setGenBusyKey(busyKey);
|
setGenBusyKey(busyKey);
|
||||||
try {
|
try {
|
||||||
return (await onGenerateBaseAsset(kind, prompt, label)) as { id?: string } | null;
|
return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult;
|
||||||
} finally {
|
} finally {
|
||||||
setGenBusyKey(null);
|
setGenBusyKey(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 据某一版立绘生成它配套的三视图(loading 用 busyKey)
|
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
|
||||||
async function genTriview(portraitGroupId: string, busyKey: string): Promise<{ id: string } | null> {
|
async function genTriview(portraitAssetId: string, busyKey: string): Promise<{ id: string } | null> {
|
||||||
if (genBusyKey) return null;
|
if (genBusyKey) return null;
|
||||||
setGenBusyKey(busyKey);
|
setGenBusyKey(busyKey);
|
||||||
try {
|
try {
|
||||||
return await onGenerateTriview(portraitGroupId);
|
return await onGenerateTriview(portraitAssetId);
|
||||||
} finally {
|
} finally {
|
||||||
setGenBusyKey(null);
|
setGenBusyKey(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘 → 据它生成新三视图
|
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图
|
||||||
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
|
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
|
||||||
const g = await genBaseAsset("person", prompt, label, busyKey);
|
const g = await genBaseAsset("person", prompt, label, busyKey);
|
||||||
if (g?.id) await genTriview(g.id, `${busyKey}:tri`);
|
const newAsset = g?.adopted_asset;
|
||||||
|
if (newAsset) await genTriview(newAsset, `${busyKey}:tri`);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
|
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
|
||||||
@@ -570,21 +567,20 @@ export function PipelinePage(props: {
|
|||||||
|
|
||||||
// ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ──
|
// ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ──
|
||||||
const [adDetail, setAdDetail] = useState<{ kind: "person" | "scene"; key: string } | null>(null);
|
const [adDetail, setAdDetail] = useState<{ kind: "person" | "scene"; key: string } | null>(null);
|
||||||
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 group
|
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 asset id(组内候选)
|
||||||
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 group
|
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 asset id
|
||||||
const [adPrompt, setAdPrompt] = useState("");
|
const [adPrompt, setAdPrompt] = useState("");
|
||||||
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.portraits.at(-1)?.id ?? null);
|
setAdPortraitId(entity.group.adopted_asset ?? entity.versions.at(-1) ?? null);
|
||||||
setAdTriId(null); // 跟随当前立绘的最新三视图
|
setAdTriId(null); // 跟随当前立绘的最新三视图
|
||||||
setAdPrompt(entity.portraits.at(-1)?.prompt ?? "");
|
setAdPrompt(entity.group.prompt ?? "");
|
||||||
}
|
}
|
||||||
// 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体最新立绘组(选演员后挂为候选并采用)
|
// 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体组(选演员后挂为候选并采用)
|
||||||
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null);
|
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null);
|
||||||
function openActorReplace(entity: AssetEntity) {
|
function openActorReplace(entity: AssetEntity) {
|
||||||
const gid = entity.portraits.at(-1)?.id;
|
setActorLib({ mode: "replace", groupId: entity.group.id });
|
||||||
if (gid) setActorLib({ mode: "replace", groupId: gid });
|
|
||||||
}
|
}
|
||||||
async function pickActor(assetId: string) {
|
async function pickActor(assetId: string) {
|
||||||
if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId);
|
if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId);
|
||||||
@@ -596,17 +592,17 @@ export function PipelinePage(props: {
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [adDetail]);
|
}, [adDetail]);
|
||||||
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘还没三视图就自动据它生成一版(每立绘组仅一次)
|
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
|
||||||
const triAutoRef = useRef<Set<string>>(new Set());
|
const triAutoRef = useRef<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adDetail || adDetail.kind !== "person" || genBusyKey) return;
|
if (!adDetail || adDetail.kind !== "person" || genBusyKey) return;
|
||||||
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
|
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
|
||||||
if (!ent || !ent.portraits.length) return;
|
if (!ent) return;
|
||||||
const portrait = ent.portraits.find((g) => g.id === adPortraitId) || ent.portraits.at(-1)!;
|
const portraitAsset = adPortraitId || ent.group.adopted_asset;
|
||||||
if (!portrait.adopted_asset || triviewsFor(portrait.id).length) return;
|
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
|
||||||
if (triAutoRef.current.has(portrait.id)) return;
|
if (triAutoRef.current.has(portraitAsset)) return;
|
||||||
triAutoRef.current.add(portrait.id);
|
triAutoRef.current.add(portraitAsset);
|
||||||
void genTriview(portrait.id, `addet-tri:${ent.key}`);
|
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [adDetail, adPortraitId]);
|
}, [adDetail, adPortraitId]);
|
||||||
// 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载)
|
// 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载)
|
||||||
@@ -2212,12 +2208,17 @@ export function PipelinePage(props: {
|
|||||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||||
{viewStage === 2 && (() => {
|
{viewStage === 2 && (() => {
|
||||||
// 流程步骤4 · 每次生成三视图 = 一个新 product group;聚合成「版本列表」,可切换 + 重跑 + 采用
|
// 流程步骤4 · 每次生成三视图 = 一个新 product group;聚合成「版本列表」,可切换 + 重跑 + 采用
|
||||||
const productGroups = groupsByKind("product").filter((g) => g.adopted_asset);
|
// 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版
|
||||||
const latestTri = productGroups.at(-1) || null;
|
const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null;
|
||||||
const productAssetUrl = groupMainUrl(latestTri) || assetUrl(productCover);
|
const productVersions = productGroup?.candidate_assets ?? [];
|
||||||
const hasTriView = productGroups.length > 0;
|
const triGenerating = genBusyKey === "tri:product";
|
||||||
|
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
||||||
|
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||||
|
const hasTriView = productVersions.length > 0;
|
||||||
|
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
|
||||||
|
const productAssetUrl = assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||||
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||||
const triSelGroup = productGroups.find((g) => g.id === triSelId) || latestTri;
|
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
||||||
return (
|
return (
|
||||||
<section className="stage active" data-stage-pane="2">
|
<section className="stage active" data-stage-pane="2">
|
||||||
<div className="stage-assets">
|
<div className="stage-assets">
|
||||||
@@ -2244,7 +2245,7 @@ export function PipelinePage(props: {
|
|||||||
<section className="asset-sec" id="asset-sec-product">
|
<section className="asset-sec" id="asset-sec-product">
|
||||||
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
|
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
|
||||||
<div className="prod-row">
|
<div className="prod-row">
|
||||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={latestTri?.adopted_asset || "prod-main"} id="asset-prod-card">
|
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
|
||||||
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
||||||
{!hasTriView && (
|
{!hasTriView && (
|
||||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||||
@@ -2268,20 +2269,53 @@ export function PipelinePage(props: {
|
|||||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prod-action" id="asset-prod-action">
|
<div className="prod-action" id="asset-prod-action">
|
||||||
{/* 行39 · 点开弹窗:可选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
|
{/* 行39 · 点击展开右侧三视图预览面板并生成一版(对齐原版 prod-preview 交互) */}
|
||||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => setTriViewOpen(true)}>
|
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={Boolean(genBusyKey)} onClick={runProductTri}>
|
||||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||||||
AI 生成三视图
|
{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`prod-preview${hasTriView ? " show" : ""}`} id="asset-prod-preview">
|
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
||||||
<div className="prod-preview-h">// 三视图版本 · <span id="prod-preview-status">{productGroups.length} 版</span></div>
|
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
|
||||||
<div className={`placeholder prod-preview-img${groupMainUrl(latestTri) ? " has-mock-media" : ""}`} id="prod-preview-img" role={latestTri ? "button" : undefined} tabIndex={latestTri ? 0 : undefined} title={latestTri ? "点击查看 / 切换版本" : undefined} style={groupMainUrl(latestTri) ? { ...mediaStyle(groupMainUrl(latestTri)), cursor: "pointer" } : undefined} onClick={latestTri ? () => { setTriSelId(latestTri.id); setTriViewOpen(true); } : undefined}><span className="ph-frame">最新版</span></div>
|
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
|
||||||
<div className="prod-preview-foot" id="prod-preview-foot">
|
{(() => {
|
||||||
{[...productGroups].reverse().slice(0, 4).map((g) => (
|
const u = candUrl(productGroup, previewTriAsset);
|
||||||
<div className={`placeholder${groupMainUrl(g) ? " has-mock-media" : ""}`} key={g.id} role="button" tabIndex={0} title="点击查看该版本" style={{ ...(groupMainUrl(g) ? mediaStyle(groupMainUrl(g)) : {}), width: "44px", height: "44px", flex: "0 0 44px", cursor: "pointer" }} onClick={() => { setTriSelId(g.id); setTriViewOpen(true); }}><span className="ph-frame"></span></div>
|
if (triGenerating && !u) {
|
||||||
))}
|
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中</span></span></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`placeholder prod-preview-img${u ? " has-mock-media is-zoomable" : ""}`} id="prod-preview-img" role={u ? "button" : undefined} tabIndex={u ? 0 : undefined} title={u ? "点击放大查看" : undefined} style={u ? mediaStyle(u) : undefined} onClick={u ? () => setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}>
|
||||||
|
{!u && <span className="ph-frame">// 暂无三视图</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{previewTriAsset && (
|
||||||
|
<div className="prod-preview-foot" id="prod-preview-foot">
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={runProductTri}>↻ 重跑</button>
|
||||||
|
<button className="btn btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12l5 5L20 6" /></svg>
|
||||||
|
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
||||||
|
</button>
|
||||||
|
<span className="spacer"></span>
|
||||||
|
<span className="muted-2 mono" style={{ fontSize: 12 }}>~¥0.30 / 次</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`prod-preview-history${productVersions.length ? " show" : ""}`} id="prod-preview-history">
|
||||||
|
<div className="h-lbl">// 历史版本 · <span className="ct">{productVersions.length}</span> 版</div>
|
||||||
|
<div className="h-row">
|
||||||
|
{productVersions.map((assetId, i) => {
|
||||||
|
const isAdopted = assetId === adoptedTriAsset;
|
||||||
|
const isPreview = assetId === previewTriAsset;
|
||||||
|
const u = candUrl(productGroup, assetId);
|
||||||
|
return (
|
||||||
|
<div className={`h-thumb${isAdopted ? " adopted" : ""}${isPreview && !isAdopted ? " previewing" : ""}${u ? " has-mock-media" : ""}`} key={assetId} data-idx={i} title={`v${i + 1}${isAdopted ? " · 已采用" : isPreview ? " · 预览中" : ""}`} role="button" tabIndex={0} style={u ? mediaStyle(u) : undefined} onClick={() => setTriPreviewId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTriPreviewId(assetId); } }}>
|
||||||
|
<span className="badge">已采用</span>
|
||||||
|
<span className="v">v{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2307,18 +2341,14 @@ export function PipelinePage(props: {
|
|||||||
return (
|
return (
|
||||||
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
|
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
|
||||||
<div className="sec-h">
|
<div className="sec-h">
|
||||||
<h3>{KIND_LABEL[kind]} · {entities.length}{tags.length ? ` / ${tags.length}` : ""} 个</h3>
|
<h3>{KIND_LABEL[kind]} · {entities.length} 个</h3>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
{kind === "person" && <button className="btn btn-ghost btn-sm" type="button" data-stop style={{ marginRight: 8 }} onClick={() => setActorLib({ mode: "browse" })}>演员库</button>}
|
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
|
||||||
<button className="btn-aigen" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { if (kind === "person") void genPersonWithTri(genPrompt, undefined, customBusy); else void genBaseAsset(kind, genPrompt, undefined, customBusy); }}>
|
<button className="btn btn-sm" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse" }); else void genBaseAsset("scene", genPrompt, undefined, customBusy); }}>{genBusyKey === customBusy ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
|
||||||
{genBusyKey === customBusy ? "生成中…" : `自定义${KIND_LABEL[kind]}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成(进入页面会自动补齐) */}
|
<div className="asset-grid-2">
|
||||||
{pendingTags.length > 0 && (
|
{/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */}
|
||||||
<div className="asset-grid-2" style={{ marginBottom: entities.length ? "10px" : 0 }}>
|
{pendingTags.map((tag) => {
|
||||||
{pendingTags.map((tag) => {
|
|
||||||
const seedKey = `seed:${kind}:${tag}`;
|
const seedKey = `seed:${kind}:${tag}`;
|
||||||
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
|
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
|
||||||
const busy = genBusyKey === seedKey;
|
const busy = genBusyKey === seedKey;
|
||||||
@@ -2326,94 +2356,60 @@ export function PipelinePage(props: {
|
|||||||
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
||||||
<div className="placeholder thumb-2">
|
<div className="placeholder thumb-2">
|
||||||
{busy
|
{busy
|
||||||
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中…</span></span>
|
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中 · 约 8s</span></div>
|
||||||
: <span className="ph-frame">{tag} · 待生成</span>}
|
: <span className="ph-frame">{tag} · 待生成</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="body-2">
|
<div className="body-2">
|
||||||
<div className="hstack">
|
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
||||||
<strong style={{ fontSize: "14px" }}>{tag}</strong>
|
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={seedKey} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||||
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<span className="pill neutral"><span className="dot"></span>来自脚本</span>
|
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||||
</div>
|
|
||||||
<textarea className="asset-prompt-edit" rows={3} placeholder="描述这个素材的提示词…" value={promptValue} onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.target.value }))} />
|
|
||||||
<div className="asset-card-actions">
|
|
||||||
<span className="spacer"></span>
|
|
||||||
<button className="btn btn-primary btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={() => { const p = promptValue.trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>
|
|
||||||
{busy ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <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 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z" /></svg>}
|
|
||||||
{busy ? "生成中…" : "AI 生成"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{/* 已生成实体卡:点缩略/标题进详情;prompt-box 可改;重跑/替换 */}
|
||||||
)}
|
{entities.map((entity) => {
|
||||||
{entities.length ? (
|
const grp = entity.group;
|
||||||
<div className="asset-grid-2">
|
const mainUrl = groupMainUrl(grp);
|
||||||
{entities.map((entity) => {
|
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
||||||
const latest = entity.portraits.at(-1)!;
|
|
||||||
const mainUrl = groupMainUrl(latest);
|
|
||||||
// 行38 · 可编辑提示词:草稿优先(按实体 key),落空回退原 prompt;重新获取据它生成
|
|
||||||
const promptValue = assetPromptDraft[entity.key] ?? latest.prompt ?? "";
|
|
||||||
const busy = genBusyKey === `ent:${kind}:${entity.key}`;
|
const busy = genBusyKey === `ent:${kind}:${entity.key}`;
|
||||||
const adopted = entity.portraits.some((g) => g.adopted_asset);
|
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
|
||||||
return (
|
return (
|
||||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||||
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||||
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
|
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
|
||||||
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
|
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
|
||||||
{busy
|
{busy
|
||||||
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中…</span></span>
|
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中…</span></div>
|
||||||
: <span className="ph-frame">{entity.name}</span>}
|
: !mainUrl && <span className="ph-frame">{entity.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="body-2">
|
<div className="body-2">
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<strong style={{ fontSize: "14px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
|
<strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
{adopted
|
{rs === "active" && <span className="pill ok" title="火山真人审核已通过"><span className="dot"></span>审核✓</span>}
|
||||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
{rs === "failed" && <span className="pill err" title={`审核未过:${byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span>审核✗</span>}
|
||||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
{rs === "processing" && <span className="pill neutral"><span className="dot"></span>审核中</span>}
|
||||||
{/* 火山真人审核绿/红盾(仅人物已采用资产) */}
|
|
||||||
{kind === "person" && latest.adopted_asset ? (() => {
|
|
||||||
const rs = assetReview(latest.adopted_asset);
|
|
||||||
if (rs === "active") return <span className="pill ok" title="火山真人审核已通过" style={{ marginLeft: 4 }}><span className="dot"></span>审核✓</span>;
|
|
||||||
if (rs === "failed") return <span className="pill err" title={`审核未过:${byId.get(latest.adopted_asset)?.review_error || "建议改提示词重新生成"}`} style={{ marginLeft: 4 }}><span className="dot"></span>审核✗·重生</span>;
|
|
||||||
if (rs === "processing") return <span className="pill neutral" style={{ marginLeft: 4 }}><span className="dot"></span>审核中</span>;
|
|
||||||
return null;
|
|
||||||
})() : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-card-tags">
|
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={entity.key} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||||
<span className="mono">// 立绘 {entity.portraits.length} 版</span>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
{kind === "person" && (() => { const n = triviewsFor(latest.id).length; return <span className="mono">// 当前立绘三视图 {n ? `${n} 版` : "未生成"}</span>; })()}
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const bk = `ent:${kind}:${entity.key}`; if (kind === "person") void genPersonWithTri(p, entity.name, bk); else void genBaseAsset(kind, p, entity.name, bk); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||||
</div>
|
|
||||||
{/* 行38/流程步骤4 · 可编辑提示词:改完点重新获取据此生成新版立绘 */}
|
|
||||||
<textarea
|
|
||||||
className="asset-prompt-edit"
|
|
||||||
rows={3}
|
|
||||||
placeholder="描述这个素材的提示词…"
|
|
||||||
value={promptValue}
|
|
||||||
onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
{/* 流程步骤4 · 重跑=据提示词生成新版立绘(覆盖当前卡片立绘,归入同角色);替换=去演员库用现有资产 */}
|
|
||||||
<div className="asset-card-actions">
|
|
||||||
<button className="btn btn-ghost btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={() => { const p = promptValue.trim() || genPrompt; const bk = `ent:${kind}:${entity.key}`; if (kind === "person") void genPersonWithTri(p, entity.name, bk); else void genBaseAsset(kind, p, entity.name, bk); }}>
|
|
||||||
{busy ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <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="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>}
|
|
||||||
{busy ? "生成中…" : "重跑"}
|
|
||||||
</button>
|
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => openActorReplace(entity)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{entities.length === 0 && pendingTags.length === 0 && (
|
||||||
) : pendingTags.length === 0 ? (
|
<div className="placeholder" style={{ gridColumn: "1 / -1", minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
||||||
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点右上「新增{KIND_LABEL[kind]}」</span>
|
||||||
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「自定义{KIND_LABEL[kind]}」生成</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2428,60 +2424,6 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 行39 · AI 生成三视图弹窗:选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
|
|
||||||
{triViewOpen && (
|
|
||||||
<div className="asset-modal-bg" onClick={() => setTriViewOpen(false)}>
|
|
||||||
<div className="asset-modal tri-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="asset-modal-h">
|
|
||||||
<h2>AI 生成三视图</h2>
|
|
||||||
<button className="x" type="button" aria-label="关闭" onClick={() => setTriViewOpen(false)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="asset-modal-body">
|
|
||||||
<div className="tri-modal-tip">
|
|
||||||
建议先生成 <b>正 / 侧 / 背</b> 三视图,后续生成的角色一致性与姿态稳定性更好;但没有三视图也可直接跑视频。
|
|
||||||
</div>
|
|
||||||
{productGroups.length ? (
|
|
||||||
<>
|
|
||||||
<div className="vd-history-h">// 三视图版本 · {productGroups.length} 版 · 点击切换查看,选中后采用</div>
|
|
||||||
<div className="tri-cand-grid">
|
|
||||||
{[...productGroups].reverse().map((g, idx) => {
|
|
||||||
const u = groupMainUrl(g);
|
|
||||||
const i = productGroups.length - idx;
|
|
||||||
const sel = g.id === (triSelGroup?.id ?? null);
|
|
||||||
return (
|
|
||||||
<div className={`tri-cand-card${sel ? " adopted" : ""}`} key={g.id} role="button" tabIndex={0}
|
|
||||||
onClick={() => setTriSelId(g.id)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTriSelId(g.id); } }}>
|
|
||||||
<div className={`placeholder tri-cand-img${u ? " has-mock-media" : ""}`} style={u ? { ...mediaStyle(u), cursor: "zoom-in" } : undefined} title="点击放大" onClick={(e) => { if (u) { e.stopPropagation(); setPreview({ src: u, kind: "image", name: `三视图 V${i}` }); } }}><span className="ph-frame">版本 {i}</span></div>
|
|
||||||
<div className="tri-cand-foot"><span className="mono">// V{i}</span><span className="btn btn-ghost btn-sm">{sel ? "已选中" : "查看"}</span></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="tri-empty">
|
|
||||||
<div className="placeholder tri-cand-img" style={{ maxWidth: 220, margin: "0 auto" }}><span className="ph-frame">// 暂无三视图</span></div>
|
|
||||||
<p className="tri-empty-hint">还没有三视图。点下方「重跑生成」让 AI 出多角度参考图,生成后回此处切换 / 采用。</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="asset-modal-f">
|
|
||||||
<button className="btn" type="button" onClick={() => setTriViewOpen(false)}>暂不生成(直接跑视频)</button>
|
|
||||||
<span className="spacer" style={{ flex: 1 }}></span>
|
|
||||||
{triSelGroup?.adopted_asset && (
|
|
||||||
<button className="btn" type="button" disabled={loading} onClick={() => { void onAdoptBaseAsset(triSelGroup.id, triSelGroup.adopted_asset!); setTriViewOpen(false); }}>采用此版本</button>
|
|
||||||
)}
|
|
||||||
<button className="btn-aigen" type="button" disabled={Boolean(genBusyKey)} onClick={() => void genBaseAsset("product", triViewGen, undefined, "tri:product")}>
|
|
||||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /></svg>
|
|
||||||
{genBusyKey === "tri:product" ? "生成中…" : (productGroups.length ? "重跑生成" : "生成三视图")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -3121,134 +3063,131 @@ export function PipelinePage(props: {
|
|||||||
const entity = entities.find((e) => e.key === adDetail.key);
|
const entity = entities.find((e) => e.key === adDetail.key);
|
||||||
if (!entity) return null;
|
if (!entity) return null;
|
||||||
const isPerson = adDetail.kind === "person";
|
const isPerson = adDetail.kind === "person";
|
||||||
const viewPortrait = entity.portraits.find((g) => g.id === adPortraitId) || entity.portraits.at(-1) || null;
|
const grp = entity.group;
|
||||||
// 三视图绑定到当前查看的这一版立绘:切立绘 → 切三视图
|
const portraitVersions = grp.candidate_assets ?? [];
|
||||||
const portraitTriviews = viewPortrait ? triviewsFor(viewPortrait.id) : [];
|
// 当前查看的立绘 asset(组内候选);默认 = 采用版
|
||||||
const viewTri = portraitTriviews.find((g) => g.id === adTriId) || portraitTriviews.at(-1) || null;
|
const viewPortraitAsset = (adPortraitId && portraitVersions.includes(adPortraitId)) ? adPortraitId : (grp.adopted_asset || portraitVersions.at(-1) || "");
|
||||||
const portraitUrl = groupMainUrl(viewPortrait);
|
const portraitUrl = candUrl(grp, viewPortraitAsset);
|
||||||
const triUrl = groupMainUrl(viewTri);
|
// 三视图绑定到当前这版立绘 asset:切立绘 → 切三视图
|
||||||
|
const triGroup = triGroupForAsset(viewPortraitAsset);
|
||||||
|
const triVersions = triGroup?.candidate_assets ?? [];
|
||||||
|
const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || "");
|
||||||
|
const triUrl = candUrl(triGroup, viewTriAsset);
|
||||||
const pBK = `addet-portrait:${entity.key}`;
|
const pBK = `addet-portrait:${entity.key}`;
|
||||||
const busyPortrait = genBusyKey === pBK;
|
const busyPortrait = genBusyKey === pBK;
|
||||||
const busyTri = genBusyKey === `addet-tri:${entity.key}` || genBusyKey === `${pBK}:tri`;
|
const busyTri = genBusyKey === `addet-tri:${entity.key}` || genBusyKey === `${pBK}:tri`;
|
||||||
async function regenPortrait() {
|
async function regenPortrait() {
|
||||||
const prompt = adPrompt.trim() || viewPortrait?.prompt || `${entity!.name},9:16 竖屏`;
|
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
||||||
// 重跑立绘 → 链式据新立绘生成它配套的三视图(人物);场景只重生立绘
|
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
|
||||||
if (adDetail!.kind === "person") await genPersonWithTri(prompt, entity!.name, pBK);
|
if (isPerson) await genPersonWithTri(prompt, entity!.name, pBK);
|
||||||
else await genBaseAsset("scene", prompt, entity!.name, pBK);
|
else await genBaseAsset("scene", prompt, entity!.name, pBK);
|
||||||
setAdPortraitId(null); // 回退到最新版(刷新后即新立绘 + 其新三视图)
|
setAdPortraitId(null); setAdTriId(null);
|
||||||
setAdTriId(null);
|
|
||||||
}
|
}
|
||||||
async function regenTri() {
|
async function regenTri() {
|
||||||
if (!viewPortrait) return;
|
if (!viewPortraitAsset) return;
|
||||||
await genTriview(viewPortrait.id, `addet-tri:${entity!.key}`); // 据当前这版立绘再出一版三视图
|
await genTriview(viewPortraitAsset, `addet-tri:${entity!.key}`); // 据当前这版立绘再出一版三视图
|
||||||
setAdTriId(null);
|
setAdTriId(null);
|
||||||
}
|
}
|
||||||
|
const zoomSvg = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>;
|
||||||
return (
|
return (
|
||||||
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={`${KIND_LABEL[adDetail.kind]}详情`} onClick={(event) => { if (event.target === event.currentTarget) setAdDetail(null); }}>
|
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={`${KIND_LABEL[adDetail.kind]}详情`} onClick={(event) => { if (event.target === event.currentTarget) setAdDetail(null); }}>
|
||||||
<div className="asset-modal">
|
<div className="asset-modal">
|
||||||
<div className="asset-modal-h">
|
<div className="asset-modal-h">
|
||||||
<h2>{KIND_LABEL[adDetail.kind]}详情 · {entity.name}</h2>
|
<h2>资产详情</h2>
|
||||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 立绘 {entity.portraits.length} 版{isPerson ? ` · 当前立绘三视图 ${portraitTriviews.length} 版` : ""}</span>
|
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]} · {entity.name}</span>
|
||||||
<button className="x" type="button" aria-label="关闭" onClick={() => setAdDetail(null)}>
|
<button className="x" type="button" aria-label="关闭" onClick={() => setAdDetail(null)}>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-modal-body">
|
<div className="asset-modal-body">
|
||||||
<div className="vd-main-wrap">
|
<div className="asset-detail-grid">
|
||||||
{/* 左:立绘大图 + hover 下载/查看大图 + 版本缩略 */}
|
{/* 左:大立绘 + 版本缩略 */}
|
||||||
<div className="ad-portrait-col">
|
<div className="asset-detail-lead">
|
||||||
<div className="ad-img-wrap">
|
<div className="ad-lead-wrap">
|
||||||
<div className={`placeholder ad-main-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
|
<div className={`placeholder ad-lead-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
|
||||||
{busyPortrait
|
{busyPortrait
|
||||||
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">立绘生成中…</span></span>
|
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">立绘生成中…</span></div>
|
||||||
: !portraitUrl && <span className="ph-frame">// 暂无立绘</span>}
|
: !portraitUrl && <span className="ph-frame">立绘</span>}
|
||||||
</div>
|
</div>
|
||||||
{portraitUrl && !busyPortrait && (
|
{portraitUrl && !busyPortrait && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
|
||||||
<div className="ad-img-icons">
|
|
||||||
{viewPortrait?.adopted_asset && <button className="ad-icon-btn" type="button" title="下载当前立绘" onClick={() => void downloadAssetImage(viewPortrait.adopted_asset!, `${entity.name}-立绘`)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14" /></svg></button>}
|
|
||||||
<button className="ad-icon-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="vd-history-h" style={{ marginTop: 10 }}>// 立绘版本 · {entity.portraits.length} 版</div>
|
|
||||||
<div className="vd-history-row">
|
|
||||||
{entity.portraits.length ? [...entity.portraits].reverse().map((g) => {
|
|
||||||
const u = groupMainUrl(g);
|
|
||||||
return (
|
|
||||||
<div className={`vd-history-thumb${g.id === viewPortrait?.id ? " current" : ""}`} key={g.id} role="button" tabIndex={0} title="点击查看该版本(三视图跟着切)" onClick={() => { setAdPortraitId(g.id); setAdTriId(null); setAdPrompt(g.prompt || ""); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdPortraitId(g.id); setAdTriId(null); setAdPrompt(g.prompt || ""); } }}>
|
|
||||||
<div className={`placeholder${u ? " has-mock-media" : ""}`} style={u ? mediaStyle(u) : undefined}></div>
|
|
||||||
<div className="ts">{(g.created_at || "").slice(11, 16) || "--:--"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无立绘版本</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
{portraitVersions.length > 0 && (
|
||||||
|
<div className="ad-thumbs">
|
||||||
|
{portraitVersions.map((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); } }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右:立绘提示词重获 + 人物三视图(场景无三视图)*/}
|
{/* 右:三视图(人物)+ 提示词重跑 */}
|
||||||
<div className="vd-info">
|
<div className="asset-detail-right">
|
||||||
<div className="vd-prompt-field" style={{ marginTop: 0 }}>
|
{isPerson && (
|
||||||
<div className="vd-prompt-head">
|
<div className="ad-section">
|
||||||
<span className="label">// 立绘提示词(重跑生效,可编辑)</span>
|
<div className="asset-detail-section-h">
|
||||||
</div>
|
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg></span>
|
||||||
<textarea className="asset-prompt-edit" rows={4} placeholder="描述这个角色的立绘…" value={adPrompt} onChange={(e) => setAdPrompt(e.target.value)} />
|
<span className="t">三视图</span>
|
||||||
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
|
<span className="ad-ratio-chip">16:9</span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={() => void regenPortrait()}>
|
{triUrl && !busyTri && <>
|
||||||
{busyPortrait ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <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="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>}
|
<button className="ad-icon-btn" type="button" title="重跑三视图" onClick={() => void regenTri()}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg></button>
|
||||||
{busyPortrait ? "生成中…" : "重跑立绘"}
|
{viewTriAsset && <button className="ad-icon-btn ad-icon-gap" type="button" title="下载当前三视图" onClick={() => void downloadAssetImage(viewTriAsset, `${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>}
|
||||||
</button>
|
</>}
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => openActorReplace(entity)}>替换</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isPerson ? (
|
|
||||||
<div style={{ marginTop: 20 }}>
|
|
||||||
<div className="vd-history-h">// 当前立绘的三视图(据该立绘生成)· {portraitTriviews.length} 版</div>
|
|
||||||
<div className="ad-tri-wrap">
|
|
||||||
<div className={`placeholder ad-tri-img${triUrl && !busyTri ? " has-mock-media" : ""}`} style={triUrl && !busyTri ? mediaStyle(triUrl) : undefined}>
|
|
||||||
{busyTri
|
|
||||||
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">三视图生成中…</span></span>
|
|
||||||
: !triUrl && <span className="ph-frame">// 三视图据立绘自动生成中…</span>}
|
|
||||||
</div>
|
|
||||||
{triUrl && !busyTri && (
|
|
||||||
<div className="ad-img-icons">
|
|
||||||
<button className="ad-icon-btn" type="button" title="重跑三视图" onClick={() => void regenTri()}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg></button>
|
|
||||||
{viewTri?.adopted_asset && <button className="ad-icon-btn" type="button" title="下载当前三视图" onClick={() => void downloadAssetImage(viewTri.adopted_asset!, `${entity.name}-三视图`)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14" /></svg></button>}
|
|
||||||
<button className="ad-icon-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm" type="button" disabled={Boolean(genBusyKey) || !viewPortrait?.adopted_asset} style={{ marginTop: 10 }} onClick={() => void regenTri()}>
|
<div className="asset-detail-tri-row">
|
||||||
{busyTri ? "生成中…" : (portraitTriviews.length ? "据当前立绘再生成一版三视图" : "据当前立绘生成三视图")}
|
<div className="ad-tri-wrap">
|
||||||
</button>
|
<div className={`placeholder${triUrl && !busyTri ? " has-mock-media" : ""}`} style={triUrl && !busyTri ? mediaStyle(triUrl) : undefined}>
|
||||||
{portraitTriviews.length > 0 && (
|
{busyTri
|
||||||
<div className="vd-history-row" style={{ marginTop: 10 }}>
|
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
|
||||||
{[...portraitTriviews].reverse().map((g) => {
|
: !triUrl && <span className="ph-frame">正 / 侧 / 背 · 三视图</span>}
|
||||||
const u = groupMainUrl(g);
|
</div>
|
||||||
return (
|
{triUrl && !busyTri && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
|
||||||
<div className={`vd-history-thumb tri${g.id === viewTri?.id ? " current" : ""}`} key={g.id} role="button" tabIndex={0} title="点击查看该三视图版本" onClick={() => setAdTriId(g.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdTriId(g.id); } }}>
|
</div>
|
||||||
<div className={`placeholder${u ? " has-mock-media" : ""}`} style={u ? mediaStyle(u) : undefined}></div>
|
</div>
|
||||||
<div className="ts">{(g.created_at || "").slice(11, 16) || "--:--"}</div>
|
{!triUrl && !busyTri && (
|
||||||
</div>
|
<div className="asset-detail-tip">
|
||||||
);
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||||
|
<span>三视图据立绘自动生成,以保证正/侧/背多角度一致</span>
|
||||||
|
<button className="ai-gen-btn" type="button" disabled={Boolean(genBusyKey) || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{triVersions.length > 0 && (
|
||||||
|
<div className="md-view-versions">
|
||||||
|
{triVersions.map((assetId, i) => {
|
||||||
|
const u = candUrl(triGroup, assetId);
|
||||||
|
return <div className={`v-thumb${assetId === viewTriAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title={`三视图 v${i + 1}`} style={u ? mediaStyle(u) : undefined} onClick={() => setAdTriId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdTriId(assetId); } }}><span className="v">v{i + 1}</span></div>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: 18 }}>// 场景资产无需三视图</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="ad-section">
|
||||||
|
<div className="asset-detail-section-h">
|
||||||
|
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6h16M4 12h16M4 18h10" /></svg></span>
|
||||||
|
<span className="t">提示词</span>
|
||||||
|
<span className="ad-ratio-chip">立绘 {portraitVersions.length} 版</span>
|
||||||
|
</div>
|
||||||
|
<textarea className="ad-detail-prompt" placeholder={isPerson ? "描述这个角色的立绘…" : "描述这个场景…"} value={adPrompt} onChange={(e) => setAdPrompt(e.target.value)} />
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
|
||||||
|
<button className="btn btn-primary btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={() => void regenPortrait()}>
|
||||||
|
{busyPortrait ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <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="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>}
|
||||||
|
{busyPortrait ? "生成中…" : (isPerson ? "重跑立绘" : "重跑")}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => openActorReplace(entity)}>替换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-modal-f">
|
<div className="asset-modal-f">
|
||||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 应用后下游故事板 / 视频按所选版本生成</span>
|
<div className="ad-foot-stats">
|
||||||
<span className="spacer" style={{ flex: 1 }}></span>
|
{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>}
|
||||||
<button className="btn btn-ghost" type="button" onClick={() => setAdDetail(null)}>关闭</button>
|
</div>
|
||||||
<button className="btn btn-primary" type="button" disabled={loading || !viewPortrait?.adopted_asset} onClick={async () => {
|
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
|
||||||
if (viewPortrait?.adopted_asset) await onAdoptBaseAsset(viewPortrait.id, viewPortrait.adopted_asset);
|
if (viewPortraitAsset) await onAdoptBaseAsset(grp.id, viewPortraitAsset);
|
||||||
if (viewTri?.adopted_asset) await onAdoptBaseAsset(viewTri.id, viewTri.adopted_asset);
|
if (triGroup && viewTriAsset) await onAdoptBaseAsset(triGroup.id, viewTriAsset);
|
||||||
setAdDetail(null);
|
setAdDetail(null);
|
||||||
}}>应用到当前项目</button>
|
}}>使用该资产</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user