feat(core): 基础资产 Agent 化 — 商品三视图(image_edit 参考主图)+ 人物立绘/三视图异步生成 + 演员库/详情

- 商品三视图:有真实商品主图时走 image_edit,以主图为参考锁包装一致(品牌字/配色/外形/Logo)
- 人物:据「某一版立绘」异步生成配套三视图(image_edit,worker 内跑),立绘/三视图各存版本可切换
- 基础资产出图改异步(run_base_asset_task / generate_triview_task),Web 层不被慢出图占住
- 前端:演员库浏览/新增、人物详情(立绘+三视图+版本切换/下载/查看大图)、按 busyKey 的单卡并发 loading(genBusy,替代全局 genBusyKey,各按钮互不阻塞)
- dev:本地连云端 MySQL 复用连接(CONN_MAX_AGE/health check/connect_timeout),仅 development 生效
- 含 projects 测试补充

tsc + py_compile + 26 后端测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 15:10:22 +08:00
co-authored by Claude Opus 4.8
parent da0c7edfe2
commit 296888f0bf
11 changed files with 361 additions and 59 deletions
+50 -2
View File
@@ -441,6 +441,45 @@ export function App() {
return pollImageTasks(mode, ids).catch(() => null);
}
// 轮询一批 AITask(基础资产/三视图异步出图)直到终态;返回成功任务产出的 assets 与最后错误。
// 轮询期间 Web 层是空闲的(只发轻量 status 请求),整站不卡;出图后调用方刷新项目即见新资产。
async function pollAiTasks(ids: string[]): Promise<{ assets: Asset[]; error: string }> {
const pending = new Set(ids);
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assets: Asset[] = [];
let error = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没好就停轮询(图仍会在后台出完,刷新可见)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]).catch(() => null);
if (!res) continue;
for (const t of res.tasks) {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) error = t.error_message;
}
}
return { assets, error };
}
// 基础资产/三视图异步出图统一入口:提交(秒回任务)→ 轮询出图 → 刷新项目 → 返回新资产 id 供链式(立绘→三视图)。
async function submitAndPollAsset(submit: () => Promise<{ task: { id: string; status: string } } | null>, okText: string): Promise<string | null> {
const submitted = await submit().catch((e) => { setNotice({ type: "error", text: e instanceof Error ? e.message : "提交失败" }); return null; });
const taskId = submitted?.task?.id;
if (!taskId) return null; // 提交失败(余额不足/无 worker 等),错误已由 submit 抛出处理
const { assets, error } = await pollAiTasks([taskId]);
await refreshProjectDetail();
void loadData();
if (assets.length === 0) {
setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" });
return null;
}
setNotice({ type: "success", text: okText });
return assets[0].id;
}
async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) {
setToken(payload.token, payload.remember ?? true);
setUser(payload.user);
@@ -564,6 +603,7 @@ export function App() {
<ProjectWizardPage
products={products}
projects={projects}
assets={assets}
preselectProductId={activeProductId}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
@@ -694,7 +734,11 @@ export function App() {
}
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={(kind, prompt, label) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成")}
onGenerateBaseAsset={async (kind, prompt, label) => {
// 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成
const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成");
return assetId ? { adopted_asset: assetId } : null;
}}
onGenerateStoryboard={(prompt) =>
action(async () => {
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
@@ -711,7 +755,11 @@ export function App() {
}
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={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
return assetId ? { id: assetId } : null;
}}
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
onUploadActor={(file) => {
const fd = new FormData();