feat(core): 基础资产页按流程文档补完 step4(人物/场景详情+演员库+三视图绑定立绘)
人物/场景: - 卡片可点开详情弹窗:立绘大图(hover 右下角 下载/查看大图)+ 立绘版本切换 + 提示词重跑(覆盖卡片立绘,归入同角色)+ 应用到当前项目;场景详情不显示三视图 - 进入基础资产自动生成脚本提取的待生成人物/场景(带 loading),每项目一次 - 「替换」→ 演员库 replace 回填;人物区头部「演员库」入口 三视图:与「某一版立绘」1:1 绑定(metadata.triview_of=立绘组id) - 生成/重跑立绘 → 链式据该立绘 image_edit 出配套三视图(锁角色一致性) - 详情切换立绘版本 → 三视图跟着切;可据当前立绘再出多版,下方版本小图可切 - 后端 generate_person_triview + 端点 generate-triview,复用 model_library 三视图 SOP 商品三视图弹窗:记录版本、切换查看、重跑生成、采用此版本 演员库(新建 components/actor-library.tsx 覆盖层): - 平台预设演员 / 我的演员两 tab + 添加演员工作台(AI 生成 / 本地上传保存) - 替换基础资产卡:新增后端 attach-base-asset(把现有资产挂为候选并采用) 合并 dev 进来的火山真人审核绿/红盾到新的实体卡。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -625,6 +625,58 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def generate_person_triview(*, project, user, portrait_group) -> "BaseAssetGroup":
|
||||||
|
"""流程步骤4 · 据「某一版立绘」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。
|
||||||
|
三视图与立绘 1:1 绑定:新立绘 → 新三视图。落一个 metadata.triview_of=<立绘组id> 的 person 组。"""
|
||||||
|
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||||||
|
|
||||||
|
if portrait_group.kind != BaseAssetGroup.Kind.PERSON:
|
||||||
|
raise ValueError("三视图仅用于人物立绘")
|
||||||
|
portrait_asset = portrait_group.adopted_asset
|
||||||
|
if portrait_asset is None:
|
||||||
|
raise ValueError("该立绘尚未生成,无法据它生成三视图")
|
||||||
|
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||||
|
if model_config is None:
|
||||||
|
raise ValueError("no active image model configured")
|
||||||
|
provider = get_image_provider(model_config)
|
||||||
|
if not hasattr(provider, "image_edit"):
|
||||||
|
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
||||||
|
ref_url = _asset_preview_url(portrait_asset)
|
||||||
|
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": str(portrait_group.id)}
|
||||||
|
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
|
||||||
|
reservation = task.credit_reservation
|
||||||
|
try:
|
||||||
|
response = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[ref_url], size="1536x1024")
|
||||||
|
media = provider.extract_first_media_url(response)
|
||||||
|
with transaction.atomic():
|
||||||
|
task.status = AITask.Status.SUCCEEDED
|
||||||
|
task.response_payload = response
|
||||||
|
task.actual_cost = task.estimated_cost
|
||||||
|
task.completed_at = timezone.now()
|
||||||
|
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||||
|
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||||
|
asset = _store_generated_media(
|
||||||
|
team=project.team, user=user, project=project, task=task, media=media,
|
||||||
|
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
|
||||||
|
)
|
||||||
|
# label 带立绘组 id:前端据此把三视图归到对应立绘版本(切立绘 → 切三视图)
|
||||||
|
group = BaseAssetGroup.objects.create(
|
||||||
|
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT,
|
||||||
|
metadata={"label": f"{portrait_group.id} ·三视图", "triview_of": str(portrait_group.id)},
|
||||||
|
)
|
||||||
|
group.candidate_assets.add(asset)
|
||||||
|
group.adopted_asset = asset
|
||||||
|
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||||
|
return group
|
||||||
|
except Exception as exc:
|
||||||
|
task.status = AITask.Status.FAILED
|
||||||
|
task.error_message = str(exc)
|
||||||
|
task.completed_at = timezone.now()
|
||||||
|
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _scene_context(project) -> str:
|
def _scene_context(project) -> str:
|
||||||
"""从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。"""
|
"""从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。"""
|
||||||
product = project.product
|
product = project.product
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from apps.ai.services import (
|
|||||||
VOICEOVER_VOICES,
|
VOICEOVER_VOICES,
|
||||||
create_export_job,
|
create_export_job,
|
||||||
generate_base_asset,
|
generate_base_asset,
|
||||||
|
generate_person_triview,
|
||||||
generate_project_script,
|
generate_project_script,
|
||||||
generate_storyboard_frame,
|
generate_storyboard_frame,
|
||||||
get_default_model,
|
get_default_model,
|
||||||
@@ -255,6 +256,36 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
promote_base_asset_stage_if_ready(project)
|
promote_base_asset_stage_if_ready(project)
|
||||||
return Response(BaseAssetGroupSerializer(group).data)
|
return Response(BaseAssetGroupSerializer(group).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="attach-base-asset")
|
||||||
|
@transaction.atomic
|
||||||
|
def attach_base_asset(self, request, pk=None):
|
||||||
|
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。"""
|
||||||
|
project = self.get_object()
|
||||||
|
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first()
|
||||||
|
if group is None:
|
||||||
|
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first()
|
||||||
|
if asset is None:
|
||||||
|
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
group.candidate_assets.add(asset)
|
||||||
|
group.adopted_asset_id = asset.id
|
||||||
|
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||||
|
promote_base_asset_stage_if_ready(project)
|
||||||
|
return Response(BaseAssetGroupSerializer(group).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="generate-triview")
|
||||||
|
def generate_triview(self, request, pk=None):
|
||||||
|
"""流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性)。"""
|
||||||
|
project = self.get_object()
|
||||||
|
portrait = BaseAssetGroup.objects.filter(project=project, id=request.data.get("portrait_group_id")).first()
|
||||||
|
if portrait is None:
|
||||||
|
return Response({"detail": "portrait group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
try:
|
||||||
|
group = generate_person_triview(project=project, user=request.user, portrait_group=portrait)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
||||||
|
|
||||||
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
|
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||||
|
|||||||
@@ -710,6 +710,17 @@ 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 }), "已替换为所选演员")}
|
||||||
|
onGenerateTriview={(portraitGroupId) => action(() => api.generateTriview(pipelineProject.id, { portrait_group_id: portraitGroupId }), "三视图已据立绘生成")}
|
||||||
|
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
||||||
|
onUploadActor={(file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("name", file.name);
|
||||||
|
fd.append("asset_type", "image");
|
||||||
|
fd.append("category", "person");
|
||||||
|
return action(() => api.uploadAsset(fd), "演员已保存到演员库");
|
||||||
|
}}
|
||||||
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) =>
|
||||||
|
|||||||
@@ -304,6 +304,14 @@ export const api = {
|
|||||||
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||||
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
|
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
|
||||||
|
attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||||
|
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
|
// 流程步骤4 · 据某一版立绘生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘组)
|
||||||
|
generateTriview(projectId: string, payload: { portrait_group_id: string }) {
|
||||||
|
return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
||||||
pollReviews(projectId: string) {
|
pollReviews(projectId: string) {
|
||||||
return request<{ reviews: Record<string, string> }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" });
|
return request<{ reviews: Record<string, string> }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" });
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { Asset } from "../types";
|
||||||
|
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||||
|
|
||||||
|
// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。
|
||||||
|
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)。
|
||||||
|
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 || "";
|
||||||
|
// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」
|
||||||
|
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated";
|
||||||
|
|
||||||
|
export function ActorLibrary({ open, mode, assets, onClose, onPick, onGenerate, onUpload, onRefresh }: {
|
||||||
|
open: boolean;
|
||||||
|
mode: "browse" | "replace";
|
||||||
|
assets: Asset[];
|
||||||
|
onClose: () => void;
|
||||||
|
onPick?: (assetId: string) => void | Promise<unknown>;
|
||||||
|
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||||
|
onUpload: (file: File) => Promise<unknown>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<"preset" | "mine">("preset");
|
||||||
|
const [studio, setStudio] = useState(false); // 添加演员工作台
|
||||||
|
const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// person 类资产 → 演员;按 preset / mine 分两 tab
|
||||||
|
const people = useMemo(() => assets.filter((a) => a.category === "person" && previewOf(a)), [assets]);
|
||||||
|
const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a)));
|
||||||
|
|
||||||
|
async function genActor() {
|
||||||
|
const p = prompt.trim() || "电商真人模特,自然光,干净背景,9:16 竖屏,正面半身";
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onGenerate(p);
|
||||||
|
onRefresh();
|
||||||
|
setStudio(false);
|
||||||
|
setTab("mine");
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function pickFile(file?: File | null) {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onUpload(file);
|
||||||
|
onRefresh();
|
||||||
|
setStudio(false);
|
||||||
|
setTab("mine");
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return createPortal(
|
||||||
|
<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-h">
|
||||||
|
<h2>{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}</h2>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"}</span>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{studio ? (
|
||||||
|
<div className="actorlib-body">
|
||||||
|
<div className="actorlib-studio-h">
|
||||||
|
<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>
|
||||||
|
返回演员库
|
||||||
|
</button>
|
||||||
|
<strong style={{ fontSize: 14 }}>添加人物工作台</strong>
|
||||||
|
</div>
|
||||||
|
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
|
||||||
|
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => setStudioMode("ai")}>AI 生成</button>
|
||||||
|
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => setStudioMode("upload")}>本地上传</button>
|
||||||
|
</div>
|
||||||
|
{studioMode === "ai" ? (
|
||||||
|
<div className="actorlib-studio-ai">
|
||||||
|
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 描述演员形象(年龄 / 风格 / 妆造 / 背景)</div>
|
||||||
|
<textarea className="asset-prompt-edit" rows={4} 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 genActor()}>
|
||||||
|
{busy ? "生成中…" : "AI 生成演员并保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="actorlib-studio-upload">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void pickFile(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="actorlib-body">
|
||||||
|
<div className="actorlib-toolbar">
|
||||||
|
<div className="actorlib-tabs">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
添加演员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{list.length ? (
|
||||||
|
<div className="actorlib-grid">
|
||||||
|
{list.map((a) => {
|
||||||
|
const url = previewOf(a);
|
||||||
|
return (
|
||||||
|
<div className="actor-card" key={a.id}>
|
||||||
|
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||||
|
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员替换" : "查看大图"}
|
||||||
|
onClick={() => { if (mode === "replace") { void onPick?.(a.id); } 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); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
|
||||||
|
{!url && <span className="ph-frame">{a.name}</span>}
|
||||||
|
{mode === "replace" && <span className="actor-pick">选用</span>}
|
||||||
|
</div>
|
||||||
|
<div className="actor-name" title={a.name}>{a.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||||
|
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -574,10 +574,56 @@
|
|||||||
.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); }
|
||||||
.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; }
|
||||||
|
|
||||||
|
/* ── 流程步骤4 · 基础资产卡生成中 loading + 转圈 ── */
|
||||||
|
.asset-card-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-base); z-index: 2; }
|
||||||
|
.asset-card-loading .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .04em; }
|
||||||
|
.asset-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-spinner.sm { width: 13px; height: 13px; border-width: 2px; display: inline-block; vertical-align: -2px; }
|
||||||
|
.asset-card-tags { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; }
|
||||||
|
.asset-card-tags .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .03em; }
|
||||||
|
|
||||||
|
/* ── 流程步骤4 · 人物/场景详情弹窗:立绘列 + 三视图 + hover 图标 ── */
|
||||||
|
.ad-portrait-col { flex: 0 0 280px; min-width: 0; }
|
||||||
|
.ad-img-wrap { position: relative; }
|
||||||
|
.ad-main-img { aspect-ratio: 9/16; max-height: 460px; border-radius: var(--r-md); overflow: hidden; }
|
||||||
|
.ad-tri-wrap { position: relative; }
|
||||||
|
.ad-tri-img { aspect-ratio: 16/9; border-radius: var(--r-md); overflow: hidden; }
|
||||||
|
.ad-img-icons { position: absolute; bottom: 8px; right: 8px; display: flex; gap: 6px; z-index: 3; }
|
||||||
|
.ad-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: var(--r-sm); background: rgba(0,0,0,.55); color: #fff; cursor: pointer; transition: background var(--t-base); }
|
||||||
|
.ad-icon-btn:hover { background: var(--heat); }
|
||||||
|
.vd-history-thumb.tri .placeholder { aspect-ratio: 16/9; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 流程步骤4 · 基础资产 loading 转圈(顶层 @keyframes 才注册) */
|
||||||
|
@keyframes assetSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* 进度流逐条滚入(顶层 @keyframes 才注册) */
|
/* 进度流逐条滚入(顶层 @keyframes 才注册) */
|
||||||
@keyframes psRowIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes psRowIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
|
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
|
||||||
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
|
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── 流程步骤4 · 演员库覆盖层(portal 到 body,顶层选择器才生效)── */
|
||||||
|
.actorlib-bg { position: fixed; inset: 0; background: rgba(0,0,0,.42); z-index: 1020; display: flex; align-items: center; justify-content: center; padding: 40px; }
|
||||||
|
.actorlib { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(920px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
|
||||||
|
.actorlib-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }
|
||||||
|
.actorlib-h h2 { font-size: 15px; font-weight: 600; }
|
||||||
|
.actorlib-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }
|
||||||
|
.actorlib-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
|
||||||
|
.actorlib-body { padding: 16px 20px 22px; overflow-y: auto; flex: 1; }
|
||||||
|
.actorlib-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.actorlib-tabs { display: inline-flex; gap: 6px; }
|
||||||
|
.al-tab { padding: 6px 12px; font-size: 13px; border: 1px solid var(--border-faint); background: var(--surface); border-radius: var(--r-sm); cursor: pointer; color: var(--black-alpha-72); transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
|
||||||
|
.al-tab:hover { border-color: var(--heat-40); }
|
||||||
|
.al-tab.active { border-color: var(--heat); background: var(--heat-12); color: var(--accent-black); font-weight: 500; }
|
||||||
|
.actorlib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||||||
|
.actor-card { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.actor-card .actor-thumb { aspect-ratio: 3/4; border-radius: var(--r-md); overflow: hidden; position: relative; cursor: pointer; border: 1px solid var(--border-faint); transition: border-color var(--t-base); }
|
||||||
|
.actor-card .actor-thumb:hover { border-color: var(--heat); }
|
||||||
|
.actor-card .actor-pick { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(250,93,25,.0); color: #fff; font-size: 13px; font-weight: 600; opacity: 0; transition: opacity var(--t-base), background var(--t-base); }
|
||||||
|
.actor-card .actor-thumb:hover .actor-pick { opacity: 1; background: rgba(250,93,25,.42); }
|
||||||
|
.actor-name { font-size: 12px; color: var(--black-alpha-72); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.actorlib-studio-h { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.actorlib-drop { border: 1.5px dashed var(--border-strong, var(--black-alpha-32)); border-radius: var(--r-md); padding: 40px 20px; text-align: center; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); }
|
||||||
|
.actorlib-drop:hover { border-color: var(--heat); background: var(--heat-12); }
|
||||||
|
|||||||
@@ -7,6 +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 { IconKitSvg } from "../components/IconKitSvg";
|
import { IconKitSvg } from "../components/IconKitSvg";
|
||||||
|
|
||||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||||
@@ -373,8 +374,14 @@ export function PipelinePage(props: {
|
|||||||
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
|
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
|
||||||
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
|
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
|
||||||
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
|
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
|
||||||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void;
|
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
|
||||||
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||||
|
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
|
||||||
|
onAttachBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
|
||||||
|
onGenerateActor: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||||
|
onUploadActor: (file: File) => Promise<unknown>;
|
||||||
|
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
|
||||||
|
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
||||||
onGenerateStoryboard: (prompt: string) => void;
|
onGenerateStoryboard: (prompt: string) => void;
|
||||||
onSkipStoryboard: () => Promise<unknown>;
|
onSkipStoryboard: () => Promise<unknown>;
|
||||||
onSubmitVideo: (segmentId: string, prompt: string) => void;
|
onSubmitVideo: (segmentId: string, prompt: string) => void;
|
||||||
@@ -391,7 +398,7 @@ export function PipelinePage(props: {
|
|||||||
const {
|
const {
|
||||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||||
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||||
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onGenerateStoryboard, onSkipStoryboard,
|
||||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
} = props;
|
} = props;
|
||||||
@@ -481,8 +488,9 @@ 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 生成三视图弹窗:打开后从该商品组选择采用哪个候选三视图(没有也可跑视频)
|
// 行39 · AI 生成三视图弹窗:记录每次生成的三视图版本(每次生成=一个 product group),可切换查看 + 重跑 + 采用
|
||||||
const [triViewOpen, setTriViewOpen] = useState(false);
|
const [triViewOpen, setTriViewOpen] = useState(false);
|
||||||
|
const [triSelId, setTriSelId] = useState<string | null>(null); // 弹窗里当前选中查看/采用的三视图版本(product group id)
|
||||||
useBodyScrollLock(triViewOpen);
|
useBodyScrollLock(triViewOpen);
|
||||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||||
setAssetTab(kind);
|
setAssetTab(kind);
|
||||||
@@ -491,6 +499,119 @@ export function PipelinePage(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 流程步骤4 · 人物/场景「实体」聚合(前端优先):后端每次生成新建一个 group,
|
||||||
|
// 这里把同一脚本标签(metadata.label)的多个 group 合成「一个角色的多版本立绘」。
|
||||||
|
// 三视图与「某一版立绘」1:1 绑定:三视图组带 metadata.triview_of=<立绘组id>,从主网格过滤掉。
|
||||||
|
type BaseGroup = (typeof groups)[number];
|
||||||
|
const triviewOf = (g?: BaseGroup | null): string => (g?.metadata?.triview_of as string) || "";
|
||||||
|
const isTriview = (g: BaseGroup) => Boolean(triviewOf(g));
|
||||||
|
// 某一版立绘(portraitGroupId)配套的三视图版本(按 created_at 升序,新的在后)
|
||||||
|
function triviewsFor(portraitGroupId: string): BaseGroup[] {
|
||||||
|
return groupsByKind("person").filter((g) => triviewOf(g) === portraitGroupId);
|
||||||
|
}
|
||||||
|
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; portraits: BaseGroup[] };
|
||||||
|
// 聚合一个 kind 下的实体列表(立绘按 label 合并版本;无 label 的各自独立成卡;三视图组不计入)
|
||||||
|
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
|
||||||
|
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
|
||||||
|
const map = new Map<string, { key: string; label: string; portraits: BaseGroup[] }>();
|
||||||
|
for (const g of portraits) {
|
||||||
|
const label = (g.metadata?.label || "").trim();
|
||||||
|
const key = label || g.id;
|
||||||
|
if (!map.has(key)) map.set(key, { key, label, portraits: [] });
|
||||||
|
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() 全局串行,这里只记「当前哪张卡在生成」做局部转圈
|
||||||
|
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> {
|
||||||
|
if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心)
|
||||||
|
setGenBusyKey(busyKey);
|
||||||
|
try {
|
||||||
|
return (await onGenerateBaseAsset(kind, prompt, label)) as { id?: string } | null;
|
||||||
|
} finally {
|
||||||
|
setGenBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 据某一版立绘生成它配套的三视图(loading 用 busyKey)
|
||||||
|
async function genTriview(portraitGroupId: string, busyKey: string): Promise<{ id: string } | null> {
|
||||||
|
if (genBusyKey) return null;
|
||||||
|
setGenBusyKey(busyKey);
|
||||||
|
try {
|
||||||
|
return await onGenerateTriview(portraitGroupId);
|
||||||
|
} finally {
|
||||||
|
setGenBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘 → 据它生成新三视图
|
||||||
|
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
|
||||||
|
const g = await genBaseAsset("person", prompt, label, busyKey);
|
||||||
|
if (g?.id) await genTriview(g.id, `${busyKey}:tri`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
|
||||||
|
async function downloadAssetImage(assetId: string, name: string) {
|
||||||
|
try {
|
||||||
|
const blob = await api.fetchAssetBlob(assetId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${name || "asset"}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||||
|
} catch { /* 取流失败忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ──
|
||||||
|
const [adDetail, setAdDetail] = useState<{ kind: "person" | "scene"; key: string } | null>(null);
|
||||||
|
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 group
|
||||||
|
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 group
|
||||||
|
const [adPrompt, setAdPrompt] = useState("");
|
||||||
|
useBodyScrollLock(Boolean(adDetail));
|
||||||
|
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
|
||||||
|
setAdDetail({ kind, key: entity.key });
|
||||||
|
setAdPortraitId(entity.portraits.at(-1)?.id ?? null);
|
||||||
|
setAdTriId(null); // 跟随当前立绘的最新三视图
|
||||||
|
setAdPrompt(entity.portraits.at(-1)?.prompt ?? "");
|
||||||
|
}
|
||||||
|
// 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体最新立绘组(选演员后挂为候选并采用)
|
||||||
|
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null);
|
||||||
|
function openActorReplace(entity: AssetEntity) {
|
||||||
|
const gid = entity.portraits.at(-1)?.id;
|
||||||
|
if (gid) setActorLib({ mode: "replace", groupId: gid });
|
||||||
|
}
|
||||||
|
async function pickActor(assetId: string) {
|
||||||
|
if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId);
|
||||||
|
setActorLib(null);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adDetail) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setAdDetail(null); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [adDetail]);
|
||||||
|
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘还没三视图就自动据它生成一版(每立绘组仅一次)
|
||||||
|
const triAutoRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adDetail || adDetail.kind !== "person" || genBusyKey) return;
|
||||||
|
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
|
||||||
|
if (!ent || !ent.portraits.length) return;
|
||||||
|
const portrait = ent.portraits.find((g) => g.id === adPortraitId) || ent.portraits.at(-1)!;
|
||||||
|
if (!portrait.adopted_asset || triviewsFor(portrait.id).length) return;
|
||||||
|
if (triAutoRef.current.has(portrait.id)) return;
|
||||||
|
triAutoRef.current.add(portrait.id);
|
||||||
|
void genTriview(portrait.id, `addet-tri:${ent.key}`);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [adDetail, adPortraitId]);
|
||||||
|
// 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载)
|
||||||
|
const autoGenRef = useRef(false);
|
||||||
|
|
||||||
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
|
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
|
||||||
const storyboards = project.storyboard_versions ?? [];
|
const storyboards = project.storyboard_versions ?? [];
|
||||||
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
|
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
|
||||||
@@ -1698,6 +1819,37 @@ 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;
|
||||||
|
|
||||||
|
// 流程步骤4 · 进入基础资产自动生成脚本里提取出、但还没生成的人物/场景占位卡(每项目仅一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeDot !== 2 && viewStage !== 2) return;
|
||||||
|
if (autoGenRef.current || genBusyKey) return;
|
||||||
|
const flagKey = `airshelf:autogen:${project.id}`;
|
||||||
|
try { if (localStorage.getItem(flagKey)) { autoGenRef.current = true; return; } } catch { /* ignore */ }
|
||||||
|
const pending: Array<{ kind: "person" | "scene"; tag: string; prompt: string }> = [];
|
||||||
|
(["person", "scene"] as const).forEach((kind) => {
|
||||||
|
const tags = kind === "person" ? (project.metadata?.cast ?? []) : (project.metadata?.scenes ?? []);
|
||||||
|
const generated = new Set(groupsByKind(kind).map((g) => (g.metadata?.label || "").trim()).filter(Boolean));
|
||||||
|
const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {};
|
||||||
|
tags.filter((t) => !generated.has(t)).forEach((tag) => {
|
||||||
|
const fallback = kind === "person"
|
||||||
|
? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏`
|
||||||
|
: `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`;
|
||||||
|
pending.push({ kind, tag, prompt: promptMap[tag]?.trim() || fallback });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!pending.length) return;
|
||||||
|
autoGenRef.current = true;
|
||||||
|
try { localStorage.setItem(flagKey, "1"); } catch { /* ignore */ }
|
||||||
|
void (async () => {
|
||||||
|
for (const item of pending) {
|
||||||
|
const bk = `seed:${item.kind}:${item.tag}`;
|
||||||
|
if (item.kind === "person") await genPersonWithTri(item.prompt, item.tag, bk);
|
||||||
|
else await genBaseAsset(item.kind, item.prompt, item.tag, bk);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeDot, viewStage, project.id]);
|
||||||
|
|
||||||
function goStage(n: number) {
|
function goStage(n: number) {
|
||||||
setViewStage(n);
|
setViewStage(n);
|
||||||
setNavigated(true);
|
setNavigated(true);
|
||||||
@@ -2059,10 +2211,13 @@ export function PipelinePage(props: {
|
|||||||
|
|
||||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||||
{viewStage === 2 && (() => {
|
{viewStage === 2 && (() => {
|
||||||
// 取最新一组(groups 已按 created_at 升序):重新生成三视图后展示新结果而不是第一次的旧组
|
// 流程步骤4 · 每次生成三视图 = 一个新 product group;聚合成「版本列表」,可切换 + 重跑 + 采用
|
||||||
const productGroup = groupsByKind("product").at(-1) || null;
|
const productGroups = groupsByKind("product").filter((g) => g.adopted_asset);
|
||||||
const productAssetUrl = groupMainUrl(productGroup) || assetUrl(productCover);
|
const latestTri = productGroups.at(-1) || null;
|
||||||
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
|
const productAssetUrl = groupMainUrl(latestTri) || assetUrl(productCover);
|
||||||
|
const hasTriView = productGroups.length > 0;
|
||||||
|
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||||
|
const triSelGroup = productGroups.find((g) => g.id === triSelId) || latestTri;
|
||||||
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">
|
||||||
@@ -2089,9 +2244,9 @@ 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={productGroup?.adopted_asset || "prod-main"} id="asset-prod-card">
|
<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={`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}>
|
||||||
{!productGroup?.adopted_asset && (
|
{!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="缺三视图,查看说明">
|
||||||
<span className="ico" aria-hidden="true"></span>
|
<span className="ico" aria-hidden="true"></span>
|
||||||
<span className="lbl-mono">缺三视图</span>
|
<span className="lbl-mono">缺三视图</span>
|
||||||
@@ -2120,12 +2275,12 @@ export function PipelinePage(props: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
|
<div className={`prod-preview${hasTriView ? " show" : ""}`} id="asset-prod-preview">
|
||||||
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
|
<div className="prod-preview-h">// 三视图版本 · <span id="prod-preview-status">{productGroups.length} 版</span></div>
|
||||||
<div className={`placeholder prod-preview-img${candUrl(productGroup, productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" role={productGroup && productCandidates[0] ? "button" : undefined} tabIndex={productGroup && productCandidates[0] ? 0 : undefined} title={productGroup && productCandidates[0] ? "点击采用此候选" : undefined} style={candUrl(productGroup, productCandidates[0]) ? { ...mediaStyle(candUrl(productGroup, productCandidates[0])), cursor: "pointer" } : undefined} onClick={productGroup && productCandidates[0] ? () => onAdoptBaseAsset(productGroup.id, productCandidates[0]) : undefined}><span className="ph-frame">候选 #1</span></div>
|
<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-foot" id="prod-preview-foot">
|
<div className="prod-preview-foot" id="prod-preview-foot">
|
||||||
{productCandidates.slice(0, 4).map((id) => (
|
{[...productGroups].reverse().slice(0, 4).map((g) => (
|
||||||
<div className={`placeholder${candUrl(productGroup, id) ? " has-mock-media" : ""}`} key={id} role="button" tabIndex={0} title="点击采用此候选" style={{ ...(candUrl(productGroup, id) ? mediaStyle(candUrl(productGroup, id)) : {}), width: "44px", height: "44px", flex: "0 0 44px", cursor: "pointer" }} onClick={productGroup ? () => onAdoptBaseAsset(productGroup.id, id) : undefined}><span className="ph-frame"></span></div>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2133,7 +2288,8 @@ export function PipelinePage(props: {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(["person", "scene"] as const).map((kind) => {
|
{(["person", "scene"] as const).map((kind) => {
|
||||||
const list = groupsByKind(kind);
|
// 流程步骤4 · 把同一脚本标签的多个 group 合成「一个角色 + 多版本立绘」实体
|
||||||
|
const entities = buildEntities(kind);
|
||||||
// 流程步骤4 · 人物←脚本提取的人物标签,场景←场景标签;提示词用脚本提取时 AI 生成的(metadata.*_prompts)
|
// 流程步骤4 · 人物←脚本提取的人物标签,场景←场景标签;提示词用脚本提取时 AI 生成的(metadata.*_prompts)
|
||||||
const tags = kind === "person" ? castTags : sceneTags;
|
const tags = kind === "person" ? castTags : sceneTags;
|
||||||
const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {};
|
const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {};
|
||||||
@@ -2141,31 +2297,38 @@ export function PipelinePage(props: {
|
|||||||
? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏`
|
? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏`
|
||||||
: `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`;
|
: `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`;
|
||||||
const tagPrompt = (tag: string) => (promptMap[tag]?.trim() || fallbackPrompt(tag));
|
const tagPrompt = (tag: string) => (promptMap[tag]?.trim() || fallbackPrompt(tag));
|
||||||
// 已生成的标签(按 group.metadata.label 归类),其余标签显示为「待生成」seed 卡
|
// 已生成的标签(实体 label),其余脚本标签显示为「待生成」seed 卡(自动生成会逐个补齐)
|
||||||
const generatedLabels = new Set(list.map((g) => g.metadata?.label).filter(Boolean) as string[]);
|
const generatedLabels = new Set(entities.map((e) => e.label).filter(Boolean));
|
||||||
const pendingTags = tags.filter((t) => !generatedLabels.has(t));
|
const pendingTags = tags.filter((t) => !generatedLabels.has(t));
|
||||||
const genPrompt = kind === "person"
|
const genPrompt = kind === "person"
|
||||||
? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
|
? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
|
||||||
: `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
|
: `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
|
||||||
|
const customBusy = `custom:${kind}`;
|
||||||
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]} · {list.length}{tags.length ? ` / ${tags.length}` : ""} 个</h3>
|
<h3>{KIND_LABEL[kind]} · {entities.length}{tags.length ? ` / ${tags.length}` : ""} 个</h3>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
|
{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); }}>
|
||||||
<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>
|
||||||
自定义{KIND_LABEL[kind]}
|
{genBusyKey === customBusy ? "生成中…" : `自定义${KIND_LABEL[kind]}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成 */}
|
{/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成(进入页面会自动补齐) */}
|
||||||
{pendingTags.length > 0 && (
|
{pendingTags.length > 0 && (
|
||||||
<div className="asset-grid-2" style={{ marginBottom: list.length ? "10px" : 0 }}>
|
<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;
|
||||||
return (
|
return (
|
||||||
<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"><span className="ph-frame">{tag} · 待生成</span></div>
|
<div className="placeholder thumb-2">
|
||||||
|
{busy
|
||||||
|
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中…</span></span>
|
||||||
|
: <span className="ph-frame">{tag} · 待生成</span>}
|
||||||
|
</div>
|
||||||
<div className="body-2">
|
<div className="body-2">
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<strong style={{ fontSize: "14px" }}>{tag}</strong>
|
<strong style={{ fontSize: "14px" }}>{tag}</strong>
|
||||||
@@ -2175,9 +2338,9 @@ export function PipelinePage(props: {
|
|||||||
<textarea className="asset-prompt-edit" rows={3} placeholder="描述这个素材的提示词…" value={promptValue} onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.target.value }))} />
|
<textarea className="asset-prompt-edit" rows={3} placeholder="描述这个素材的提示词…" value={promptValue} onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.target.value }))} />
|
||||||
<div className="asset-card-actions">
|
<div className="asset-card-actions">
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || tagPrompt(tag)), tag)}>
|
<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); }}>
|
||||||
<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 ? <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>}
|
||||||
AI 生成
|
{busy ? "生成中…" : "AI 生成"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2186,71 +2349,71 @@ export function PipelinePage(props: {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{list.length ? (
|
{entities.length ? (
|
||||||
<div className="asset-grid-2">
|
<div className="asset-grid-2">
|
||||||
{list.map((group, gi) => {
|
{entities.map((entity) => {
|
||||||
const mainUrl = groupMainUrl(group);
|
const latest = entity.portraits.at(-1)!;
|
||||||
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
|
const mainUrl = groupMainUrl(latest);
|
||||||
// 行38 · 可编辑提示词:草稿优先,落空回退原 prompt;重跑/替换都带它
|
// 行38 · 可编辑提示词:草稿优先(按实体 key),落空回退原 prompt;重新获取据它生成
|
||||||
const promptValue = assetPromptDraft[group.id] ?? group.prompt ?? "";
|
const promptValue = assetPromptDraft[entity.key] ?? latest.prompt ?? "";
|
||||||
// 流程步骤4 · 该卡对应的脚本标签(若有),作卡片标题并在重跑/替换时保留归类
|
const busy = genBusyKey === `ent:${kind}:${entity.key}`;
|
||||||
const label = group.metadata?.label || "";
|
const adopted = entity.portraits.some((g) => g.adopted_asset);
|
||||||
const cardName = label || assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`;
|
|
||||||
return (
|
return (
|
||||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
|
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||||
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
|
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||||
<span className="ph-frame">{cardName}</span>
|
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
|
||||||
|
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
|
||||||
|
{busy
|
||||||
|
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中…</span></span>
|
||||||
|
: <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" }}>{cardName}</strong>
|
<strong style={{ fontSize: "14px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
{group.adopted_asset
|
{adopted
|
||||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
||||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
||||||
{/* 火山真人审核绿/红盾(仅人物已采用资产) */}
|
{/* 火山真人审核绿/红盾(仅人物已采用资产) */}
|
||||||
{kind === "person" && group.adopted_asset ? (() => {
|
{kind === "person" && latest.adopted_asset ? (() => {
|
||||||
const rs = assetReview(group.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 === "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(group.adopted_asset)?.review_error || "建议改提示词重新生成"}`} 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>;
|
if (rs === "processing") return <span className="pill neutral" style={{ marginLeft: 4 }}><span className="dot"></span>审核中</span>;
|
||||||
return null;
|
return null;
|
||||||
})() : null}
|
})() : null}
|
||||||
</div>
|
</div>
|
||||||
{/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */}
|
<div className="asset-card-tags">
|
||||||
|
<span className="mono">// 立绘 {entity.portraits.length} 版</span>
|
||||||
|
{kind === "person" && (() => { const n = triviewsFor(latest.id).length; return <span className="mono">// 当前立绘三视图 {n ? `${n} 版` : "未生成"}</span>; })()}
|
||||||
|
</div>
|
||||||
|
{/* 行38/流程步骤4 · 可编辑提示词:改完点重新获取据此生成新版立绘 */}
|
||||||
<textarea
|
<textarea
|
||||||
className="asset-prompt-edit"
|
className="asset-prompt-edit"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="描述这个素材的提示词…"
|
placeholder="描述这个素材的提示词…"
|
||||||
value={promptValue}
|
value={promptValue}
|
||||||
onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [group.id]: e.target.value }))}
|
onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
{cands.length > 0 && (
|
{/* 流程步骤4 · 重跑=据提示词生成新版立绘(覆盖当前卡片立绘,归入同角色);替换=去演员库用现有资产 */}
|
||||||
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
|
|
||||||
{cands.map((id) => (
|
|
||||||
<div className={`placeholder${candUrl(group, id) ? " has-mock-media" : ""}`} key={id} role="button" tabIndex={0} title="点击采用此候选" style={{ ...(candUrl(group, id) ? mediaStyle(candUrl(group, id)) : {}), width: "40px", height: "40px", flex: "0 0 40px", cursor: "pointer" }} onClick={() => onAdoptBaseAsset(group.id, id)}><span className="ph-frame"></span></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 行38 · 重跑 / 替换:重跑=据当前提示词重新生成;替换=有候选先采下一张,无候选回退重生成 */}
|
|
||||||
<div className="asset-card-actions">
|
<div className="asset-card-actions">
|
||||||
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt), label)}>
|
<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); }}>
|
||||||
<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 ? <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>
|
</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => { if (cands.length) { void onAdoptBaseAsset(group.id, cands[0]); } else { onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt), label); } }}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => openActorReplace(entity)}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : pendingTags.length === 0 ? (
|
||||||
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
||||||
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成</span>
|
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「自定义{KIND_LABEL[kind]}」生成</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2279,18 +2442,20 @@ export function PipelinePage(props: {
|
|||||||
<div className="tri-modal-tip">
|
<div className="tri-modal-tip">
|
||||||
建议先生成 <b>正 / 侧 / 背</b> 三视图,后续生成的角色一致性与姿态稳定性更好;但没有三视图也可直接跑视频。
|
建议先生成 <b>正 / 侧 / 背</b> 三视图,后续生成的角色一致性与姿态稳定性更好;但没有三视图也可直接跑视频。
|
||||||
</div>
|
</div>
|
||||||
{productCandidates.length ? (
|
{productGroups.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="vd-history-h">// 候选三视图 · 选择采用哪个版本</div>
|
<div className="vd-history-h">// 三视图版本 · {productGroups.length} 版 · 点击切换查看,选中后采用</div>
|
||||||
<div className="tri-cand-grid">
|
<div className="tri-cand-grid">
|
||||||
{productCandidates.map((id, i) => {
|
{[...productGroups].reverse().map((g, idx) => {
|
||||||
const u = candUrl(productGroup, id);
|
const u = groupMainUrl(g);
|
||||||
|
const i = productGroups.length - idx;
|
||||||
|
const sel = g.id === (triSelGroup?.id ?? null);
|
||||||
return (
|
return (
|
||||||
<div className={`tri-cand-card${productGroup?.adopted_asset === id ? " adopted" : ""}`} key={id} role="button" tabIndex={0}
|
<div className={`tri-cand-card${sel ? " adopted" : ""}`} key={g.id} role="button" tabIndex={0}
|
||||||
onClick={() => { if (productGroup) { void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}
|
onClick={() => setTriSelId(g.id)}
|
||||||
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && productGroup) { e.preventDefault(); void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}>
|
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) : undefined}><span className="ph-frame">版本 {i + 1}</span></div>
|
<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 + 1}</span><span className="btn btn-ghost btn-sm">采用此版本</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2298,17 +2463,20 @@ export function PipelinePage(props: {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="tri-empty">
|
<div className="tri-empty">
|
||||||
<div className="placeholder tri-cand-img" style={{ maxWidth: 220, margin: "0 auto" }}><span className="ph-frame">// 暂无三视图候选</span></div>
|
<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>
|
<p className="tri-empty-hint">还没有三视图。点下方「重跑生成」让 AI 出多角度参考图,生成后回此处切换 / 采用。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-modal-f">
|
<div className="asset-modal-f">
|
||||||
<button className="btn" type="button" onClick={() => setTriViewOpen(false)}>暂不生成(直接跑视频)</button>
|
<button className="btn" type="button" onClick={() => setTriViewOpen(false)}>暂不生成(直接跑视频)</button>
|
||||||
<span className="spacer" style={{ flex: 1 }}></span>
|
<span className="spacer" style={{ flex: 1 }}></span>
|
||||||
<button className="btn-aigen" type="button" disabled={loading} onClick={() => { onGenerateBaseAsset("product", `${productName} 三视图`); setTriViewOpen(false); }}>
|
{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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2947,6 +3115,156 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
|
||||||
|
{adDetail && (() => {
|
||||||
|
const entities = buildEntities(adDetail.kind);
|
||||||
|
const entity = entities.find((e) => e.key === adDetail.key);
|
||||||
|
if (!entity) return null;
|
||||||
|
const isPerson = adDetail.kind === "person";
|
||||||
|
const viewPortrait = entity.portraits.find((g) => g.id === adPortraitId) || entity.portraits.at(-1) || null;
|
||||||
|
// 三视图绑定到当前查看的这一版立绘:切立绘 → 切三视图
|
||||||
|
const portraitTriviews = viewPortrait ? triviewsFor(viewPortrait.id) : [];
|
||||||
|
const viewTri = portraitTriviews.find((g) => g.id === adTriId) || portraitTriviews.at(-1) || null;
|
||||||
|
const portraitUrl = groupMainUrl(viewPortrait);
|
||||||
|
const triUrl = groupMainUrl(viewTri);
|
||||||
|
const pBK = `addet-portrait:${entity.key}`;
|
||||||
|
const busyPortrait = genBusyKey === pBK;
|
||||||
|
const busyTri = genBusyKey === `addet-tri:${entity.key}` || genBusyKey === `${pBK}:tri`;
|
||||||
|
async function regenPortrait() {
|
||||||
|
const prompt = adPrompt.trim() || viewPortrait?.prompt || `${entity!.name},9:16 竖屏`;
|
||||||
|
// 重跑立绘 → 链式据新立绘生成它配套的三视图(人物);场景只重生立绘
|
||||||
|
if (adDetail!.kind === "person") await genPersonWithTri(prompt, entity!.name, pBK);
|
||||||
|
else await genBaseAsset("scene", prompt, entity!.name, pBK);
|
||||||
|
setAdPortraitId(null); // 回退到最新版(刷新后即新立绘 + 其新三视图)
|
||||||
|
setAdTriId(null);
|
||||||
|
}
|
||||||
|
async function regenTri() {
|
||||||
|
if (!viewPortrait) return;
|
||||||
|
await genTriview(viewPortrait.id, `addet-tri:${entity!.key}`); // 据当前这版立绘再出一版三视图
|
||||||
|
setAdTriId(null);
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<div className="asset-modal-h">
|
||||||
|
<h2>{KIND_LABEL[adDetail.kind]}详情 · {entity.name}</h2>
|
||||||
|
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 立绘 {entity.portraits.length} 版{isPerson ? ` · 当前立绘三视图 ${portraitTriviews.length} 版` : ""}</span>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="asset-modal-body">
|
||||||
|
<div className="vd-main-wrap">
|
||||||
|
{/* 左:立绘大图 + hover 下载/查看大图 + 版本缩略 */}
|
||||||
|
<div className="ad-portrait-col">
|
||||||
|
<div className="ad-img-wrap">
|
||||||
|
<div className={`placeholder ad-main-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
|
||||||
|
{busyPortrait
|
||||||
|
? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">立绘生成中…</span></span>
|
||||||
|
: !portraitUrl && <span className="ph-frame">// 暂无立绘</span>}
|
||||||
|
</div>
|
||||||
|
{portraitUrl && !busyPortrait && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 右:立绘提示词重获 + 人物三视图(场景无三视图)*/}
|
||||||
|
<div className="vd-info">
|
||||||
|
<div className="vd-prompt-field" style={{ marginTop: 0 }}>
|
||||||
|
<div className="vd-prompt-head">
|
||||||
|
<span className="label">// 立绘提示词(重跑生效,可编辑)</span>
|
||||||
|
</div>
|
||||||
|
<textarea className="asset-prompt-edit" rows={4} placeholder="描述这个角色的立绘…" value={adPrompt} onChange={(e) => setAdPrompt(e.target.value)} />
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
|
||||||
|
<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 ? "生成中…" : "重跑立绘"}
|
||||||
|
</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>
|
||||||
|
<button className="btn btn-sm" type="button" disabled={Boolean(genBusyKey) || !viewPortrait?.adopted_asset} style={{ marginTop: 10 }} onClick={() => void regenTri()}>
|
||||||
|
{busyTri ? "生成中…" : (portraitTriviews.length ? "据当前立绘再生成一版三视图" : "据当前立绘生成三视图")}
|
||||||
|
</button>
|
||||||
|
{portraitTriviews.length > 0 && (
|
||||||
|
<div className="vd-history-row" style={{ marginTop: 10 }}>
|
||||||
|
{[...portraitTriviews].reverse().map((g) => {
|
||||||
|
const u = groupMainUrl(g);
|
||||||
|
return (
|
||||||
|
<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 className={`placeholder${u ? " has-mock-media" : ""}`} style={u ? mediaStyle(u) : undefined}></div>
|
||||||
|
<div className="ts">{(g.created_at || "").slice(11, 16) || "--:--"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: 18 }}>// 场景资产无需三视图</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="asset-modal-f">
|
||||||
|
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 应用后下游故事板 / 视频按所选版本生成</span>
|
||||||
|
<span className="spacer" style={{ flex: 1 }}></span>
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={() => setAdDetail(null)}>关闭</button>
|
||||||
|
<button className="btn btn-primary" type="button" disabled={loading || !viewPortrait?.adopted_asset} onClick={async () => {
|
||||||
|
if (viewPortrait?.adopted_asset) await onAdoptBaseAsset(viewPortrait.id, viewPortrait.adopted_asset);
|
||||||
|
if (viewTri?.adopted_asset) await onAdoptBaseAsset(viewTri.id, viewTri.adopted_asset);
|
||||||
|
setAdDetail(null);
|
||||||
|
}}>应用到当前项目</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
|
||||||
|
<ActorLibrary
|
||||||
|
open={Boolean(actorLib)}
|
||||||
|
mode={actorLib?.mode || "browse"}
|
||||||
|
assets={assets}
|
||||||
|
onClose={() => setActorLib(null)}
|
||||||
|
onPick={pickActor}
|
||||||
|
onGenerate={onGenerateActor}
|
||||||
|
onUpload={onUploadActor}
|
||||||
|
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)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user